WIP
This commit is contained in:
parent
1b21ee1573
commit
ca84a0f99f
10 changed files with 233 additions and 40 deletions
17
config.toml
17
config.toml
|
|
@ -1,8 +1,21 @@
|
|||
[instance]
|
||||
base_uri = "http://localhost:8085"
|
||||
name = "Example org"
|
||||
base_uri = "https://auth.fictive.org"
|
||||
name = "Fictive's auth"
|
||||
logo_uri = "https://example.org/logo.png"
|
||||
|
||||
[[applications]]
|
||||
slug = "listmonk"
|
||||
name = "Listmonk"
|
||||
description = "Newsletter tool."
|
||||
client_id = "da2120b4-635d-4eb5-8b2f-dbae89f6a6e9"
|
||||
client_secret = "59da2291-8999-40e2-afe9-a54ac7cd0a94"
|
||||
login_uri = "https://lists.fictive.org"
|
||||
allowed_redirect_uris = [
|
||||
"https://lists.fictive.org/auth/oidc",
|
||||
]
|
||||
visibility = "Internal"
|
||||
authorize_flow = "Implicit"
|
||||
|
||||
[[applications]]
|
||||
slug = "demo_app"
|
||||
name = "Demo app"
|
||||
|
|
|
|||
20
justfile
20
justfile
|
|
@ -2,17 +2,17 @@ export RUST_BACKTRACE := "1"
|
|||
export RUST_LOG := "trace"
|
||||
export CONTEXT_ARGS := "--config config.toml --database tmp/dbs/minauthator.db --static-assets ./assets"
|
||||
|
||||
watch-server:
|
||||
cargo-watch -x "run --bin minauthator-server -- $CONTEXT_ARGS"
|
||||
watch-server *args:
|
||||
cargo-watch -x "run --bin minauthator-server -- $CONTEXT_ARGS {{args}}"
|
||||
|
||||
server:
|
||||
cargo run --bin minauthator-server -- $CONTEXT_ARGS
|
||||
server *args:
|
||||
cargo run --bin minauthator-server -- $CONTEXT_ARGS {{args}}
|
||||
|
||||
admin:
|
||||
cargo run --bin minauthator-admin -- $CONTEXT_ARGS
|
||||
admin *args:
|
||||
cargo run --bin minauthator-admin -- $CONTEXT_ARGS {{args}}
|
||||
|
||||
docker-build:
|
||||
docker build -t lefuturiste/minauthator .
|
||||
docker-build *args:
|
||||
docker build -t lefuturiste/minauthator {{args}} .
|
||||
|
||||
docker-init-db:
|
||||
docker run \
|
||||
|
|
@ -28,6 +28,6 @@ docker-run:
|
|||
-v minauthator-db:/var/lib/minauthator \
|
||||
lefuturiste/minauthator
|
||||
|
||||
init-db:
|
||||
sqlite3 -echo tmp/dbs/minauthator.db < migrations/all.sql
|
||||
init-db *args:
|
||||
sqlite3 {{args}} tmp/dbs/minauthator.db < migrations/all.sql
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ use fully_pub::fully_pub;
|
|||
use log::error;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use kernel::models::authorization::Authorization;
|
||||
use kernel::{models::authorization::Authorization, repositories::users::get_user_by_id};
|
||||
use crate::{
|
||||
services::{app_session::AppClientSession, session::create_token}, token_claims::AppUserTokenClaims, AppState
|
||||
services::{app_session::AppClientSession, session::create_token}, token_claims::{AppUserTokenClaims, OAuth2AccessTokenClaims, OIDCIdTokenClaims}, AppState
|
||||
};
|
||||
|
||||
const AUTHORIZATION_CODE_TTL_SECONDS: i64 = 120;
|
||||
|
|
@ -22,6 +22,7 @@ struct AccessTokenRequestParams {
|
|||
#[derive(Serialize, Deserialize)]
|
||||
#[fully_pub]
|
||||
struct AccessTokenResponse {
|
||||
id_token: String,
|
||||
access_token: String,
|
||||
token_type: String,
|
||||
expires_in: u64
|
||||
|
|
@ -60,6 +61,7 @@ pub async fn get_access_token(
|
|||
).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 2.2. Validate that the authorization code is not expired
|
||||
let is_code_valid = authorization.last_used_at
|
||||
.map_or(false, |ts| {
|
||||
|
|
@ -72,19 +74,35 @@ pub async fn get_access_token(
|
|||
).into_response();
|
||||
}
|
||||
|
||||
// 3. Generate JWT for oauth2 client user session
|
||||
let jwt = create_token(
|
||||
// 2.3. Fetch user resource owner
|
||||
let user = get_user_by_id(&app_state.db, &authorization.user_id)
|
||||
.await
|
||||
.expect("Expected to get user from authorization.");
|
||||
|
||||
// 3.1. Generate JWT for OAuth2 client user session
|
||||
let access_token_jwt = create_token(
|
||||
&app_state.secrets,
|
||||
AppUserTokenClaims::new(
|
||||
OAuth2AccessTokenClaims::new(
|
||||
&app_state.config,
|
||||
&app_client_session.client_id,
|
||||
&authorization.user_id,
|
||||
&user,
|
||||
authorization.scopes.to_vec()
|
||||
)
|
||||
);
|
||||
// 3.2. Generate id_token for OIDC client
|
||||
let id_token_jwt = create_token(
|
||||
&app_state.secrets,
|
||||
OIDCIdTokenClaims::new(
|
||||
&app_state.config,
|
||||
&app_client_session.client_id,
|
||||
&user
|
||||
)
|
||||
);
|
||||
// 4. return JWT
|
||||
let access_token_res = AccessTokenResponse {
|
||||
access_token: jwt,
|
||||
token_type: "jwt".to_string(),
|
||||
id_token: id_token_jwt,
|
||||
access_token: access_token_jwt,
|
||||
token_type: "Bearer".to_string(),
|
||||
expires_in: 3600
|
||||
};
|
||||
Json(access_token_res).into_response()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use strum::IntoEnumIterator;
|
|||
|
||||
use crate::AppState;
|
||||
|
||||
/// Manifest used by OpenID Connect clients
|
||||
/// @See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
|
||||
#[derive(Serialize)]
|
||||
#[fully_pub]
|
||||
struct WellKnownOpenIdConfiguration {
|
||||
|
|
@ -15,7 +17,8 @@ struct WellKnownOpenIdConfiguration {
|
|||
userinfo_endpoint: String,
|
||||
scopes_supported: Vec<String>,
|
||||
response_types_supported: Vec<String>,
|
||||
token_endpoint_auth_methods_supported: Vec<String>
|
||||
token_endpoint_auth_methods_supported: Vec<String>,
|
||||
id_token_signing_alg_values_supported: Vec<String>
|
||||
}
|
||||
|
||||
pub async fn get_well_known_openid_configuration(
|
||||
|
|
@ -30,5 +33,8 @@ pub async fn get_well_known_openid_configuration(
|
|||
scopes_supported: AuthorizationScope::iter().map(|v| v.to_string()).collect(),
|
||||
response_types_supported: vec!["code".into()],
|
||||
token_endpoint_auth_methods_supported: vec!["client_secret_basic".into()],
|
||||
id_token_signing_alg_values_supported: vec!["HS256".into()],
|
||||
// jwks_uri:
|
||||
// subject_types_supported
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ pub async fn authorize_form(
|
|||
query_params: Query<AuthorizationParams>
|
||||
) -> impl IntoResponse {
|
||||
let Query(authorization_params) = query_params;
|
||||
dbg!(&authorization_params);
|
||||
|
||||
// 1. Verify the app details
|
||||
let app = match app_state.config.applications
|
||||
|
|
|
|||
|
|
@ -12,9 +12,16 @@ pub fn verify_redirect_uri(app: &Application, input_redirect_uri: &str) -> bool
|
|||
pub fn parse_scope(scope_str: &str) -> Result<Vec<AuthorizationScope>> {
|
||||
let mut scopes: Vec<AuthorizationScope> = vec![];
|
||||
for part in scope_str.split(' ') {
|
||||
scopes.push(
|
||||
AuthorizationScope::from_str(part).context("Cannot parse space-delimited scope.")?
|
||||
)
|
||||
if part == "openid" {
|
||||
scopes.push(AuthorizationScope::UserReadBasic);
|
||||
scopes.push(AuthorizationScope::UserReadRoles);
|
||||
continue;
|
||||
}
|
||||
if part == "profile" || part == "email" {
|
||||
continue;
|
||||
}
|
||||
scopes.push(AuthorizationScope::from_str(part).context("Cannot parse space-delimited scope.")?);
|
||||
}
|
||||
dbg!(&scopes);
|
||||
Ok(scopes)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use fully_pub::fully_pub;
|
||||
use jsonwebtoken::get_current_timestamp;
|
||||
use kernel::models::authorization::AuthorizationScope;
|
||||
use kernel::models::{authorization::AuthorizationScope, config::Config, user::User};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::Duration;
|
||||
|
||||
|
|
@ -26,27 +26,67 @@ impl UserTokenClaims {
|
|||
}
|
||||
}
|
||||
|
||||
/// Access token for OAuth2 defined in RFC 9068
|
||||
/// @See https://datatracker.ietf.org/doc/html/rfc9068
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[fully_pub]
|
||||
struct AppUserTokenClaims {
|
||||
/// combined subject
|
||||
client_id: String,
|
||||
user_id: String,
|
||||
scopes: Vec<AuthorizationScope>,
|
||||
/// token expiration
|
||||
struct OAuth2AccessTokenClaims {
|
||||
/// Token issuer (URI to the issuer)
|
||||
iss: String,
|
||||
/// Audiance (client_id)
|
||||
aud: String,
|
||||
/// End-user id assigned by the issuer (user_id)
|
||||
sub: String,
|
||||
/// Token expiration
|
||||
exp: u64,
|
||||
/// token issuer
|
||||
iss: String
|
||||
/// List of OAuth 2 scopes asked by the client
|
||||
scopes: Vec<AuthorizationScope>
|
||||
}
|
||||
|
||||
impl AppUserTokenClaims {
|
||||
pub fn new(client_id: &str, user_id: &str, scopes: Vec<AuthorizationScope>) -> Self {
|
||||
AppUserTokenClaims {
|
||||
client_id: client_id.into(),
|
||||
user_id: user_id.into(),
|
||||
scopes,
|
||||
impl OAuth2AccessTokenClaims {
|
||||
pub fn new(config: &Config, client_id: &str, user: &User, scopes: Vec<AuthorizationScope>) -> Self {
|
||||
OAuth2AccessTokenClaims {
|
||||
iss: config.instance.base_uri.clone(),
|
||||
aud: client_id.into(),
|
||||
sub: user.id.clone(),
|
||||
exp: get_current_timestamp() + 86_000,
|
||||
iss: "Minauth".into()
|
||||
scopes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// @See https://openid.net/specs/openid-connect-core-1_0.html#IDToken
|
||||
/// @See https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[fully_pub]
|
||||
struct OIDCIdTokenClaims {
|
||||
/// Token expiration
|
||||
exp: u64,
|
||||
/// Token issuer (URI to the issuer)
|
||||
iss: String,
|
||||
/// Audiance (client_id)
|
||||
aud: String,
|
||||
/// End-user id assigned by the issuer (user_id)
|
||||
sub: String,
|
||||
/// additional claims
|
||||
name: Option<String>,
|
||||
email: Option<String>,
|
||||
preferred_username: Option<String>,
|
||||
roles: Vec<String>
|
||||
}
|
||||
|
||||
impl OIDCIdTokenClaims {
|
||||
pub fn new(config: &Config, client_id: &str, user: &User) -> Self {
|
||||
OIDCIdTokenClaims {
|
||||
iss: config.instance.base_uri.clone(),
|
||||
aud: client_id.into(),
|
||||
sub: user.id.clone(),
|
||||
exp: get_current_timestamp() + 86_000,
|
||||
email: user.email.clone(),
|
||||
name: user.full_name.clone(),
|
||||
preferred_username: Some(user.handle.clone()),
|
||||
roles: user.roles.0.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
tests/hurl_integration/oidc_core/config.toml
Normal file
56
tests/hurl_integration/oidc_core/config.toml
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
[instance]
|
||||
base_uri = "http://localhost:8086"
|
||||
name = "Example org"
|
||||
logo_uri = "https://example.org/logo.png"
|
||||
|
||||
[[applications]]
|
||||
slug = "demo_app"
|
||||
name = "Demo app"
|
||||
description = "A super application where you can do everything you want."
|
||||
client_id = "00000001-0000-0000-0000-000000000001"
|
||||
client_secret = "dummy_client_secret"
|
||||
login_uri = "https://localhost:9876"
|
||||
allowed_redirect_uris = [
|
||||
"http://localhost:9090/callback",
|
||||
"http://localhost:9876/callback"
|
||||
]
|
||||
visibility = "Internal"
|
||||
authorize_flow = "Implicit"
|
||||
|
||||
[[applications]]
|
||||
slug = "wiki"
|
||||
name = "Wiki app"
|
||||
description = "The knowledge base of the exemple org."
|
||||
client_id = "f9de1885-448d-44bb-8c48-7e985486a8c6"
|
||||
client_secret = "49c6c16a-0a8a-4981-a60d-5cb96582cc1a"
|
||||
login_uri = "https://wiki.example.org/login"
|
||||
allowed_redirect_uris = [
|
||||
"https://wiki.example.org/oauth2/callback"
|
||||
]
|
||||
visibility = "Public"
|
||||
authorize_flow = "Implicit"
|
||||
|
||||
[[applications]]
|
||||
slug = "private_app"
|
||||
name = "Demo app"
|
||||
description = "Private app you should never discover"
|
||||
client_id = "c8a08783-2342-4ce3-a3cb-9dc89b6bdf"
|
||||
client_secret = "this_is_the_secret"
|
||||
login_uri = "https://private-app.org"
|
||||
allowed_redirect_uris = [
|
||||
"http://localhost:9091/authorize",
|
||||
]
|
||||
visibility = "Private"
|
||||
authorize_flow = "Implicit"
|
||||
|
||||
[[roles]]
|
||||
slug = "basic"
|
||||
name = "Basic"
|
||||
description = "Basic user"
|
||||
default = true
|
||||
|
||||
[[roles]]
|
||||
slug = "admin"
|
||||
name = "Administrator"
|
||||
description = "Full power on organization instance"
|
||||
|
||||
11
tests/hurl_integration/oidc_core/init_db.sh
Executable file
11
tests/hurl_integration/oidc_core/init_db.sh
Executable file
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/bash
|
||||
|
||||
password_hash="$(echo -n "root" | argon2 salt_06cGGWYDJCZ -e)"
|
||||
echo $password_hash
|
||||
SQL=$(cat <<EOF
|
||||
INSERT INTO users
|
||||
(id, handle, email, roles, status, password_hash, created_at)
|
||||
VALUES
|
||||
('$(uuid)', 'john.doe', 'john.doe@example.org', '[]', 'Active', '$password_hash', '2024-11-30T00:00:00Z');
|
||||
EOF)
|
||||
echo $SQL | sqlite3 $DB_PATH
|
||||
41
tests/hurl_integration/oidc_core/main.hurl
Normal file
41
tests/hurl_integration/oidc_core/main.hurl
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
POST {{ base_url }}/login
|
||||
[FormParams]
|
||||
login: root
|
||||
password: root
|
||||
HTTP 303
|
||||
[Captures]
|
||||
user_jwt: cookie "minauthator_jwt"
|
||||
|
||||
# OAuth2 implicit flow (pre-granted app)
|
||||
GET {{ base_url }}/authorize
|
||||
[QueryStringParams]
|
||||
client_id: 00000001-0000-0000-0000-000000000001
|
||||
response_type: code
|
||||
redirect_uri: http://localhost:9090/callback
|
||||
state: Afk4kf6pbZkms78jM
|
||||
scope: openid profile email
|
||||
HTTP 302
|
||||
[Captures]
|
||||
authorization_code: header "Location" regex "\\?code=(.*)&"
|
||||
|
||||
# OIDC Token exchange
|
||||
POST {{ base_url }}/api/token
|
||||
[BasicAuth]
|
||||
00000001-0000-0000-0000-000000000001: dummy_client_secret
|
||||
[FormParams]
|
||||
code: {{ authorization_code }}
|
||||
scope: user_read_basic
|
||||
redirect_uri: http://localhost:9090/callback
|
||||
grant_type: authorization_code
|
||||
HTTP 200
|
||||
Content-Type: application/json
|
||||
[Asserts]
|
||||
jsonpath "$.access_token" exists
|
||||
jsonpath "$.id_token" exists
|
||||
jsonpath "$.id_token" matches "eyJ[[:alpha:]0-9].[[:alpha:]0-9].[[:alpha:]0-9]"
|
||||
[Captures]
|
||||
id_token: jsonpath "$.id_token"
|
||||
|
||||
# TODO: assert id_token JWT claims fields
|
||||
# TODO: contribute to hurl to add JWT extraction and assertion
|
||||
# See. https://github.com/Orange-OpenSource/hurl/issues/2223
|
||||
Loading…
Add table
Add a link
Reference in a new issue