Bläddra i källkod

Added code response and token endpoint, working end-to-end flow.

Kestrel 11 månader sedan
förälder
incheckning
5603ac6bf5
10 ändrade filer med 317 tillägg och 111 borttagningar
  1. 7 5
      simple-setup.sh
  2. 7 0
      src/config.rs
  3. 17 0
      src/schema.rs
  4. 2 1
      src/server.rs
  5. 224 74
      src/server/oidc.rs
  6. 28 16
      src/server/oidc/api.rs
  7. 3 5
      src/server/session.rs
  8. 27 8
      src/server/um.rs
  9. 1 1
      src/token.rs
  10. 1 1
      src/token_management.rs

+ 7 - 5
simple-setup.sh

@@ -1,5 +1,7 @@
 #!/bin/bash
 
+set -e
+
 cargo build
 UIDC=./target/debug/uidc
 
@@ -8,15 +10,15 @@ $UIDC config load /dev/stdin <<EOF
 base_url = "http://localhost:2114"
 EOF
 
-$UIDC key generate
-$UIDC client create testclient
+$UIDC key generate ed25519
+$UIDC client create testclient ed25519
 $UIDC user create kestrel
 echo "please enter password for user 'kestrel'"
 $UIDC user update-auth -p kestrel
 
 $UIDC group create testgroup
 $UIDC role create testrole
-$UIDC group attach-role testgroup testrole
-$UIDC group attach-user testgroup kestrel
+$UIDC group attach testgroup roles testrole
+$UIDC group attach testgroup users kestrel
 $UIDC scope create testscope
-$UIDC scope attach-role testscope testrole
+$UIDC scope attach testscope roles testrole

+ 7 - 0
src/config.rs

@@ -8,12 +8,19 @@ fn default_auth_token_expiry() -> u64 {
     600
 }
 
+fn default_auth_code_expiry() -> u64 {
+    600
+}
+
 #[derive(Debug, Serialize, Deserialize)]
 pub struct Config {
     pub base_url: String,
 
     #[serde(default = "default_auth_token_expiry")]
     pub auth_token_expiry: u64,
+
+    #[serde(default = "default_auth_code_expiry")]
+    pub auth_code_expiry: u64,
 }
 
 impl Config {

+ 17 - 0
src/schema.rs

@@ -88,6 +88,7 @@ pub struct Realm {
     pub roles: microrm::RelationMap<Role>,
     pub scopes: microrm::RelationMap<Scope>,
     pub users: microrm::RelationMap<User>,
+    pub auth_codes: microrm::RelationMap<AuthCode>,
 }
 
 #[derive(Entity)]
@@ -148,6 +149,22 @@ pub struct ClientRedirect {
     pub redirect: String,
 }
 
+#[derive(Entity)]
+pub struct AuthCode {
+    #[key]
+    pub realm: RealmID,
+    #[key]
+    pub client: ClientID,
+    #[key]
+    pub code: String,
+
+    pub expiry: time::OffsetDateTime,
+
+    pub user: UserID,
+    pub scopes: microrm::Serialized<Vec<String>>,
+    pub redirect_uri: String,
+}
+
 /// Requested group of permissions
 #[derive(Entity)]
 pub struct Scope {

+ 2 - 1
src/server.rs

@@ -32,7 +32,8 @@ async fn index(req: tide::Request<ServerStateWrapper>) -> tide::Result<tide::Res
             session: {session:?}
             auth: {auth:?}
             um link: {link}
-            "#, link = req.url().join("um/").unwrap()
+            "#,
+            link = req.url().join("um/").unwrap()
         ))
         .build();
     Ok(response)

+ 224 - 74
src/server/oidc.rs

@@ -1,10 +1,12 @@
-use crate::{key, schema, token, UIDCError};
+use crate::{config, key, schema, token, UIDCError};
 use microrm::prelude::*;
 use ring::signature::KeyPair;
 use serde::{Deserialize, Serialize};
 
 use super::session::SessionHelper;
 
+mod api;
+
 type Request = tide::Request<super::ServerStateWrapper>;
 
 #[derive(serde::Serialize)]
@@ -42,6 +44,16 @@ impl<'a> OIDCError<'a> {
     }
 }
 
+impl<'a> From<microrm::Error> for OIDCError<'a> {
+    fn from(value: microrm::Error) -> Self {
+        Self(
+            OIDCErrorType::ServerError,
+            format!("Internal database error: {value}"),
+            None,
+        )
+    }
+}
+
 #[derive(Deserialize)]
 struct AuthorizeQueryParams {
     response_type: String,
@@ -50,21 +62,83 @@ struct AuthorizeQueryParams {
     scope: Option<String>,
 }
 
-fn do_code_authorize(
-    request: Request,
-    qp: AuthorizeQueryParams,
-    state: Option<&str>,
-    client: microrm::Stored<schema::Client>,
-) -> Result<tide::Response, OIDCError> {
-    todo!()
+fn do_code_authorize<'l, 's>(
+    config: &config::Config,
+    realm: &microrm::Stored<schema::Realm>,
+    client: &microrm::Stored<schema::Client>,
+    user: &microrm::Stored<schema::User>,
+    scopes: impl Iterator<Item = &'l str>,
+    redirect_uri: String,
+    state: Option<&'s str>,
+) -> Result<tide::Response, OIDCError<'s>> {
+    let expiry =
+        std::time::SystemTime::now() + std::time::Duration::from_secs(config.auth_code_expiry);
+
+    let rng = ring::rand::SystemRandom::new();
+    let raw_auth_code: [u8; 32] = ring::rand::generate(&rng)
+        .map_err(|_| {
+            OIDCError(
+                OIDCErrorType::ServerError,
+                format!("Failed to generate auth code."),
+                state,
+            )
+        })?
+        .expose();
+    let encoded_auth_code = base64::encode_config(raw_auth_code, base64::URL_SAFE_NO_PAD);
+
+    realm.auth_codes.insert(schema::AuthCode {
+        realm: realm.id(),
+        client: client.id(),
+        user: user.id(),
+        scopes: scopes
+            .map(|x| x.to_owned())
+            .collect::<Vec<_>>()
+            .into_serialized(),
+        expiry: expiry.into(),
+        redirect_uri: redirect_uri.clone(),
+        code: encoded_auth_code.clone(),
+    })?;
+
+    let new_params = [
+        ("response_type", "code"),
+        ("code", encoded_auth_code.as_str()),
+    ]
+    .into_iter()
+    .chain(state.map(|v| ("state", v)));
+
+    Ok(tide::Redirect::temporary(
+        tide::http::Url::parse_with_params(redirect_uri.as_str(), new_params).map_err(|e| {
+            OIDCError(
+                OIDCErrorType::InvalidRequest,
+                format!("could not parse redirect_uri as a URL: {e}"),
+                None,
+            )
+        })?,
+    )
+    .into())
 }
 
-fn do_token_authorize(
-    request: Request,
-    qp: AuthorizeQueryParams,
-    state: Option<&str>,
-    client: microrm::Stored<schema::Client>,
-) -> Result<tide::Response, OIDCError> {
+fn do_token_authorize<'l, 's>(
+    config: &config::Config,
+    realm: &schema::Realm,
+    client: &schema::Client,
+    user: &schema::User,
+    scopes: impl Iterator<Item = &'l str>,
+    state: Option<&'s str>,
+) -> Result<tide::Response, OIDCError<'s>> {
+    let token = token::generate_access_token(config, &realm, client, user, scopes.into_iter());
+
+    // TODO: use api::TokenResponse here
+    let response_body = serde_json::json!({
+        "token": token.map_err(|e| OIDCError(OIDCErrorType::ServerError, format!("error while generating token: {:?}", e), state))?,
+    });
+    Ok(tide::Response::builder(200)
+        .content_type(tide::http::mime::JSON)
+        .body(response_body)
+        .build())
+}
+
+fn do_authorize(request: Request, state: Option<&str>) -> Result<tide::Response, OIDCError> {
     let shelper = SessionHelper::new(&request);
     let realm = shelper.get_realm().map_err(|_| {
         OIDCError(
@@ -75,7 +149,7 @@ fn do_token_authorize(
     })?;
 
     let make_redirect = || {
-        let mut login_url = request.url().join("../session/login").unwrap();
+        let mut login_url = request.url().join("../v1/session/login").unwrap();
         login_url
             .query_pairs_mut()
             .clear()
@@ -83,6 +157,12 @@ fn do_token_authorize(
         return Ok(tide::Redirect::new(login_url).into());
     };
 
+    let qp: AuthorizeQueryParams = request
+        .query()
+        .map_err(|x| OIDCError(OIDCErrorType::InvalidRequest, x.to_string(), state))?;
+
+    // collect session authentication info
+
     let potential_sauth = shelper
         .get_session(&request)
         .and_then(|session| shelper.get_auth_for_session(realm.id(), &session));
@@ -105,46 +185,6 @@ fn do_token_authorize(
         ));
     };
 
-    let scopes = qp
-        .scope
-        .as_ref()
-        .map(|slist| slist.as_str())
-        .unwrap_or("")
-        .split_whitespace();
-
-    // TODO: check that redirect URI matches
-
-    let token = token::generate_auth_token(
-        &request.state().core.config,
-        &realm,
-        client.as_ref(),
-        user.as_ref(),
-        scopes,
-    );
-
-    let response_body = serde_json::json!({
-        "token": token.map_err(|e| OIDCError(OIDCErrorType::ServerError, format!("error while generating token: {:?}", e), state))?,
-    });
-    Ok(tide::Response::builder(200)
-        .content_type(tide::http::mime::JSON)
-        .body(response_body)
-        .build())
-}
-
-fn do_authorize(request: Request, state: Option<&str>) -> Result<tide::Response, OIDCError> {
-    let shelper = SessionHelper::new(&request);
-    let realm = shelper.get_realm().map_err(|_| {
-        OIDCError(
-            OIDCErrorType::InvalidRequest,
-            "No such realm!".to_string(),
-            state,
-        )
-    })?;
-
-    let qp: AuthorizeQueryParams = request
-        .query()
-        .map_err(|x| OIDCError(OIDCErrorType::InvalidRequest, x.to_string(), state))?;
-
     // verify the realm and client_id and redirect_uri
 
     let client = realm
@@ -162,10 +202,34 @@ fn do_authorize(request: Request, state: Option<&str>) -> Result<tide::Response,
             )
         })?;
 
+    let scopes = qp
+        .scope
+        .as_ref()
+        .map(|slist| slist.as_str())
+        .unwrap_or("")
+        .split_whitespace();
+
+    // TODO: check that redirect URI matches
+
     if qp.response_type == "code" {
-        do_code_authorize(request, qp, state, client)
+        do_code_authorize(
+            &request.state().core.config,
+            &realm,
+            &client,
+            &user,
+            scopes,
+            qp.redirect_uri,
+            state,
+        )
     } else if qp.response_type == "token" {
-        do_token_authorize(request, qp, state, client)
+        do_token_authorize(
+            &request.state().core.config,
+            &realm,
+            &client,
+            &user,
+            scopes,
+            state,
+        )
     } else {
         Err(OIDCError(
             OIDCErrorType::UnsupportedResponseType,
@@ -188,24 +252,111 @@ async fn authorize(request: Request) -> tide::Result<tide::Response> {
     }
 }
 
-#[derive(Deserialize)]
-struct TokenRequestBody {
-    grant_type: String,
-    refresh_token: Option<String>,
-    scope: Option<String>,
-    redirect_uri: Option<String>,
-    code: Option<String>,
-    client_id: Option<String>,
+async fn do_token<'l>(mut request: Request) -> Result<tide::Response, OIDCError<'l>> {
+    let shelper = SessionHelper::new(&request);
+    let realm = shelper
+        .get_realm()
+        .map_err(|_| OIDCError(OIDCErrorType::InvalidRequest, "no such realm".into(), None))?;
 
-    // direct grant
-    username: Option<String>,
-    password: Option<String>,
+    let treq: api::TokenRequestBody = request.body_form().await.map_err(|e| {
+        OIDCError(
+            OIDCErrorType::InvalidRequest,
+            format!("could not parse form body: {e}"),
+            None,
+        )
+    })?;
+
+    // TODO: support HTTP basic auth for client authentication instead of treq.client_id
+    let client_name = treq.client_id.ok_or(OIDCError(
+        OIDCErrorType::InvalidRequest,
+        "no client given".into(),
+        None,
+    ))?;
+    let client = realm
+        .clients
+        .keyed((realm.id(), &client_name))
+        .get()?
+        .ok_or(OIDCError(
+            OIDCErrorType::InvalidRequest,
+            format!("unknown client name {client_name}"),
+            None,
+        ))?;
+
+    if treq.grant_type == "authorization_code" {
+        let Some(code) = treq.code else { todo!() };
+        let code = realm
+            .auth_codes
+            .keyed((realm.id(), client.id(), code))
+            .get()?
+            .ok_or(OIDCError(
+                OIDCErrorType::InvalidRequest,
+                "invalid authorization code".into(),
+                None,
+            ))?;
+
+        let now = std::time::SystemTime::now();
+        if code.expiry < now {
+            return Err(OIDCError(
+                OIDCErrorType::InvalidRequest,
+                "expired authorization code".into(),
+                None,
+            ));
+        }
+
+        let user = realm
+            .users
+            .with(schema::UserID::default(), code.user)
+            .first()
+            .get()?
+            .ok_or(OIDCError(
+                OIDCErrorType::ServerError,
+                "could not find user".into(),
+                None,
+            ))?;
+
+        let access_token = token::generate_access_token(
+            &request.state().core.config,
+            &realm,
+            &client,
+            &user,
+            code.scopes.as_ref().iter().map(String::as_str),
+        )
+        .map_err(|e| {
+            OIDCError(
+                OIDCErrorType::ServerError,
+                format!("error signing key: {e}"),
+                None,
+            )
+        })?;
+
+        Ok(tide::Response::builder(200)
+            .content_type(tide::http::mime::JSON)
+            .body(
+                serde_json::to_value(api::TokenResponse {
+                    access_token: access_token.as_str(),
+                    token_type: "bearer",
+                    refresh_token: None,
+                    scope: None,
+                })
+                .unwrap(),
+            )
+            .build())
+    } else if treq.grant_type == "refresh_token" {
+        todo!()
+    } else {
+        Err(OIDCError(
+            OIDCErrorType::InvalidRequest,
+            format!("unknown grant type {}", treq.grant_type),
+            None,
+        ))
+    }
 }
 
-async fn token(mut request: Request) -> tide::Result<tide::Response> {
-    let body: Result<TokenRequestBody, _> = request.body_form().await;
-    // let body : TokenRequestBody = request.body_form();
-    todo!()
+async fn token(request: Request) -> tide::Result<tide::Response> {
+    match do_token(request).await {
+        Ok(res) => Ok(res),
+        Err(e) => Ok(e.to_response()),
+    }
 }
 
 const AUTHORIZE_PATH: &'static str = "oidc/authorize";
@@ -213,9 +364,8 @@ const TOKEN_PATH: &'static str = "oidc/token";
 const JWKS_PATH: &'static str = "oidc/jwks";
 
 async fn jwks(request: Request) -> tide::Result<tide::Response> {
-    let shelper = super::session::SessionHelper::new(&request);
+    let shelper = SessionHelper::new(&request);
     let realm = shelper.get_realm()?;
-    // let rkeys = key::RealmKeys::new(realm.wrapped());
 
     let keyinfo =
         realm

+ 28 - 16
src/server/oidc/api.rs

@@ -1,19 +1,19 @@
-pub use serde::{Serialize,Deserialize};
+pub use serde::{Deserialize, Serialize};
 
 #[derive(Deserialize)]
 pub struct AuthorizationRequestQuery {
-    response_type: String,
-    client_id: String,
-    redirect_uri: String,
-    scope: Option<String>,
-    state: Option<String>
+    pub response_type: String,
+    pub client_id: String,
+    pub redirect_uri: String,
+    pub scope: Option<String>,
+    pub state: Option<String>,
 }
 
 #[derive(Serialize)]
-pub struct AuthorizationResponse {
-    response_type: String,
-    state: Option<String>,
-    code: Option<String>,
+pub struct AuthorizationResponse<'l> {
+    pub response_type: &'l str,
+    pub state: Option<&'l str>,
+    pub code: Option<&'l str>,
 }
 
 #[derive(Serialize)]
@@ -24,21 +24,33 @@ pub enum AuthorizationResponseErrorType {
     UnsupportedResponseType,
     InvalidScope,
     ServerError,
-    TemporarilyUnavailable
+    TemporarilyUnavailable,
 }
 
 #[derive(Serialize)]
 pub struct AuthorizationResponseError {
     state: Option<String>,
-    error: AuthorizationResponseErrorType
+    error: AuthorizationResponseErrorType,
 }
 
 #[derive(Deserialize)]
-pub struct TokenRequestParameters {
-    
+pub struct TokenRequestBody {
+    pub grant_type: String,
+    pub refresh_token: Option<String>,
+    pub scope: Option<String>,
+    pub redirect_uri: Option<String>,
+    pub code: Option<String>,
+    pub client_id: Option<String>,
+
+    // direct grant
+    pub username: Option<String>,
+    pub password: Option<String>,
 }
 
 #[derive(Serialize)]
-pub struct TokenResponse {
-    
+pub struct TokenResponse<'l> {
+    pub access_token: &'l str,
+    pub token_type: &'l str,
+    pub refresh_token: Option<&'l str>,
+    pub scope: Option<&'l str>,
 }

+ 3 - 5
src/server/session.rs

@@ -131,7 +131,7 @@ impl<'l> SessionHelper<'l> {
 
         if to_present.is_none() {
             response.set_status(302);
-            tide::Redirect::new("../..").into()
+            tide::Redirect::new(redirect).into()
         } else {
             self.render_login_page(response, redirect, to_present.unwrap(), error_msg)
         }
@@ -196,6 +196,7 @@ impl<'l> SessionHelper<'l> {
 }
 
 async fn v1_login(req: Request) -> tide::Result<tide::Response> {
+    log::info!("in v1_login");
     let mut response = tide::Response::builder(200).build();
 
     let shelper = SessionHelper::new(&req);
@@ -245,6 +246,7 @@ async fn v1_login_post(mut req: Request) -> tide::Result<tide::Response> {
     // check if a login reset was requested; if so, we start again from the top
     if body.reset.is_some() {
         shelper.destroy_auth(realm.id(), &session)?;
+        // TODO: include original redirect URL here
         return Ok(tide::Redirect::new("login").into());
     }
 
@@ -315,10 +317,6 @@ async fn v1_login_post(mut req: Request) -> tide::Result<tide::Response> {
         }
         ct => {
             if let Some(auth) = auth.as_mut() {
-                // let qi = req.state().core.pool.query_interface();
-
-                // let user = qi.get().by_id(&auth.user).one().expect("couldn't query db");
-
                 if let Some(user_id) = auth.pending_user {
                     let user = realm
                         .users

+ 27 - 8
src/server/um.rs

@@ -1,7 +1,7 @@
 use microrm::prelude::*;
 use tide::http::mime;
 
-use crate::{schema, UIDCError, user::UserExt};
+use crate::{schema, user::UserExt, UIDCError};
 
 type Request = tide::Request<super::ServerStateWrapper>;
 
@@ -9,7 +9,14 @@ fn generate_template_data(
     realm: &microrm::Stored<schema::Realm>,
     user: &microrm::Stored<schema::User>,
 ) -> Result<serde_json::Value, UIDCError> {
-    let has_totp = user.auth.with(schema::AuthChallenge::ChallengeType, schema::AuthChallengeType::TOTP.into_serialized()).count()? > 0;
+    let has_totp = user
+        .auth
+        .with(
+            schema::AuthChallenge::ChallengeType,
+            schema::AuthChallengeType::TOTP.into_serialized(),
+        )
+        .count()?
+        > 0;
 
     let template_data = serde_json::json!({
         "username": user.username,
@@ -38,7 +45,12 @@ async fn um_index(req: Request) -> tide::Result<tide::Response> {
         }
     };
 
-    let user = realm.users.with(schema::UserID::default(), user_id).first().get()?.unwrap();
+    let user = realm
+        .users
+        .with(schema::UserID::default(), user_id)
+        .first()
+        .get()?
+        .unwrap();
 
     let template_data = generate_template_data(&realm, &user)?;
 
@@ -74,7 +86,12 @@ async fn um_update(mut req: Request) -> tide::Result<tide::Response> {
         }
     };
 
-    let user = realm.users.with(schema::UserID::default(), user_id).first().get()?.unwrap();
+    let user = realm
+        .users
+        .with(schema::UserID::default(), user_id)
+        .first()
+        .get()?
+        .unwrap();
 
     log::info!("processing update request...");
 
@@ -112,7 +129,9 @@ async fn um_update(mut req: Request) -> tide::Result<tide::Response> {
                 info_msgs.push("Cleared TOTP setup".into());
             } else if totp == "reset" {
                 let (_secret, _uri) = user.generate_totp_with_uri()?;
-                Err(UIDCError::Abort("totp setup outside of cli not (yet) supported"))?
+                Err(UIDCError::Abort(
+                    "totp setup outside of cli not (yet) supported",
+                ))?
             }
         }
 
@@ -152,9 +171,9 @@ async fn um_update(mut req: Request) -> tide::Result<tide::Response> {
 }
 
 pub(super) fn um_server(mut route: tide::Route<super::ServerStateWrapper>) {
-    route.at("um").get(|_req| async {
-        Ok(tide::Redirect::permanent("um/"))
-    });
+    route
+        .at("um")
+        .get(|_req| async { Ok(tide::Redirect::permanent("um/")) });
     route.at("um/").get(um_index);
     route.at("um/update").post(um_update);
     // route.at("/change_password").get(um_change_password).post(um_change_password_post);

+ 1 - 1
src/token.rs

@@ -20,7 +20,7 @@ impl From<ring::error::KeyRejected> for TokenError {
     }
 }
 
-pub fn generate_auth_token<'a>(
+pub fn generate_access_token<'a>(
     config: &config::Config,
     realm: &schema::Realm,
     client: &schema::Client,

+ 1 - 1
src/token_management.rs

@@ -8,7 +8,7 @@ pub fn create_auth_token(
     username: &String,
     scopes: &String,
 ) -> Result<String, UIDCError> {
-    token::generate_auth_token(
+    token::generate_access_token(
         config,
         realm,
         &realm