kych

OAuth 2.0 API for Swiyu to enable Taler integration of Swiyu for KYC (experimental)
Log | Files | Refs | README

commit ec8943a7c130157b55cf73dcd9643880782acd26
parent 7235585a40be138a2843645c6704f07cb3d6d7e4
Author: Henrique Chan Carvalho Machado <henriqueccmachado@tecnico.ulisboa.pt>
Date:   Mon, 19 Jan 2026 13:29:39 +0100

oauth2_gateway: add polling; clean up oauth2 gateway

- Add polling endpoint and HTML UI with static QR JS lib.
- Fix OAuth2 redirect_uri validation and status query handling.
- Standardize kych- naming for binaries and folders.
- Update sequence diagrams. Webhook worker removed from flows.
- Improve logging with level filtering flag: -L INFO or DEBUG.

Diffstat:
Mdocumentation/sequence_diagrams/authorize_sequence.txt | 35+++++++++++++++++------------------
Mdocumentation/sequence_diagrams/info_sequence.txt | 27+++++++++++++--------------
Mdocumentation/sequence_diagrams/notification_sequence.txt | 71++++++++++++++++++++++++++++++++++++++---------------------------------
Mdocumentation/sequence_diagrams/setup_sequence.txt | 25++++++++++++-------------
Mdocumentation/sequence_diagrams/swiyu_taler_sequence_diagram.txt | 75+++++++++++++++++++++++++++++++++++++++++----------------------------------
Mdocumentation/sequence_diagrams/token_sequence.txt | 39+++++++++++++++++++--------------------
Akych_oauth2_gateway/Cargo.toml | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Roauth2_gateway/clients.conf.example -> kych_oauth2_gateway/clients.conf.example | 0
Roauth2_gateway/config.ini.example -> kych_oauth2_gateway/config.ini.example | 0
Roauth2_gateway/env.example -> kych_oauth2_gateway/env.example | 0
Akych_oauth2_gateway/js/qrcode.min.js | 2++
Roauth2_gateway/oauth2_gatewaydb/drop.sql -> kych_oauth2_gateway/oauth2_gatewaydb/drop.sql | 0
Roauth2_gateway/oauth2_gatewaydb/install_db.sh -> kych_oauth2_gateway/oauth2_gatewaydb/install_db.sh | 0
Roauth2_gateway/oauth2_gatewaydb/oauth2gw-0001.sql -> kych_oauth2_gateway/oauth2_gatewaydb/oauth2gw-0001.sql | 0
Roauth2_gateway/oauth2_gatewaydb/uninstall_db.sh -> kych_oauth2_gateway/oauth2_gatewaydb/uninstall_db.sh | 0
Roauth2_gateway/oauth2_gatewaydb/versioning.sql -> kych_oauth2_gateway/oauth2_gatewaydb/versioning.sql | 0
Roauth2_gateway/openapi.yaml -> kych_oauth2_gateway/openapi.yaml | 0
Akych_oauth2_gateway/src/bin/client_management_cli.rs | 420+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akych_oauth2_gateway/src/bin/webhook_worker.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Roauth2_gateway/src/config.rs -> kych_oauth2_gateway/src/config.rs | 0
Roauth2_gateway/src/crypto.rs -> kych_oauth2_gateway/src/crypto.rs | 0
Roauth2_gateway/src/db/authorization_codes.rs -> kych_oauth2_gateway/src/db/authorization_codes.rs | 0
Roauth2_gateway/src/db/clients.rs -> kych_oauth2_gateway/src/db/clients.rs | 0
Roauth2_gateway/src/db/mod.rs -> kych_oauth2_gateway/src/db/mod.rs | 0
Roauth2_gateway/src/db/notification_webhooks.rs -> kych_oauth2_gateway/src/db/notification_webhooks.rs | 0
Akych_oauth2_gateway/src/db/sessions.rs | 369+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Roauth2_gateway/src/db/tokens.rs -> kych_oauth2_gateway/src/db/tokens.rs | 0
Akych_oauth2_gateway/src/handlers.rs | 997+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Roauth2_gateway/src/lib.rs -> kych_oauth2_gateway/src/lib.rs | 0
Akych_oauth2_gateway/src/main.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Akych_oauth2_gateway/src/models.rs | 291++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Roauth2_gateway/src/state.rs -> kych_oauth2_gateway/src/state.rs | 0
Roauth2_gateway/src/worker.rs -> kych_oauth2_gateway/src/worker.rs | 0
Akych_oauth2_gateway/templates/authorize.html | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Roauth2_gateway/tests/client_cli.rs -> kych_oauth2_gateway/tests/client_cli.rs | 0
Roauth2_gateway/tests/db.rs -> kych_oauth2_gateway/tests/db.rs | 0
Doauth2_gateway/Cargo.toml | 66------------------------------------------------------------------
Doauth2_gateway/src/bin/client_management_cli.rs | 420-------------------------------------------------------------------------------
Doauth2_gateway/src/bin/webhook_worker.rs | 97-------------------------------------------------------------------------------
Doauth2_gateway/src/db/sessions.rs | 332-------------------------------------------------------------------------------
Doauth2_gateway/src/handlers.rs | 819-------------------------------------------------------------------------------
Doauth2_gateway/src/main.rs | 85-------------------------------------------------------------------------------
Doauth2_gateway/src/models.rs | 276-------------------------------------------------------------------------------
43 files changed, 2678 insertions(+), 2227 deletions(-)

diff --git a/documentation/sequence_diagrams/authorize_sequence.txt b/documentation/sequence_diagrams/authorize_sequence.txt @@ -1,37 +1,37 @@ sequenceDiagram participant Client - participant OAuth2 Gateway - participant OAuth2 Gateway DB + participant Kych oauth2 Gateway + participant Kych oauth2 Gateway DB participant Swiyu Verifier - Client ->> OAuth2 Gateway: GET /authorize?\nresponse_type=code&\nclient_id={client_id}&\nnonce={nonce} + Client ->> Kych oauth2 Gateway: GET /authorize?\nresponse_type=code&\nclient_id={client_id}&\nnonce={nonce} - OAuth2 Gateway ->> OAuth2 Gateway: Validate parameters:\n- response_type == 'code' + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Validate parameters:\n- response_type == 'code' alt Invalid parameters - OAuth2 Gateway -->> Client: 400 BAD REQUEST\n{error: 'invalid_request'} + Kych oauth2 Gateway -->> Client: 400 BAD REQUEST\n{error: 'invalid_request'} else Valid parameters - OAuth2 Gateway ->> OAuth2 Gateway DB: UPDATE verification_sessions s \nSET status = s.status \nFROM clients c \nWHERE s.client_id = c.id \nAND s.nonce = $1 AND c.client_id = $2 \nRETURNING s.id, s.status, s.expires_at, \ns.scope, s.verification_url, \ns.request_id, s.verifier_nonce, \nc.verifier_url, c.verifier_management_api_path + Kych oauth2 Gateway ->> Kych oauth2 Gateway DB: UPDATE verification_sessions s \nSET status = s.status \nFROM clients c \nWHERE s.client_id = c.id \nAND s.nonce = $1 AND c.client_id = $2 \nRETURNING s.id, s.status, s.expires_at, \ns.scope, s.verification_url, \ns.request_id, s.verifier_nonce, \nc.verifier_url, c.verifier_management_api_path - OAuth2 Gateway DB -->> OAuth2 Gateway: Query result + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: Query result end alt Session error - OAuth2 Gateway -->> Client: Error Response:\n- 404 NOT FOUND (session not found)\n- 410 GONE (expired)\n- 409 CONFLICT (not pending) + Kych oauth2 Gateway -->> Client: Error Response:\n- 404 NOT FOUND (session not found)\n- 410 GONE (expired)\n- 409 CONFLICT (not pending) else Session already authorized (idempotent) - OAuth2 Gateway -->> Client: 200 OK \n{verification_id, verification_url} + Kych oauth2 Gateway -->> Client: 200 OK \n{verification_id, verification_url} else Session valid and pending - proceed - OAuth2 Gateway ->> OAuth2 Gateway: build_presentation_definition(scope) + Kych oauth2 Gateway ->> Kych oauth2 Gateway: build_presentation_definition(scope) - OAuth2 Gateway ->> Swiyu Verifier: POST /management/api/verifications \n{presentation_definition, response_mode, ...} - Swiyu Verifier -->> OAuth2 Gateway: Response + Kych oauth2 Gateway ->> Swiyu Verifier: POST /management/api/verifications \n{presentation_definition, response_mode, ...} + Swiyu Verifier -->> Kych oauth2 Gateway: Response alt Error - OAuth2 Gateway -->> Client: Error Response:\n- 502 BAD GATEWAY (verifier error)\n- 500 INTERNAL SERVER ERROR (DB error) + Kych oauth2 Gateway -->> Client: Error Response:\n- 502 BAD GATEWAY (verifier error)\n- 500 INTERNAL SERVER ERROR (DB error) else Success - OAuth2 Gateway ->> OAuth2 Gateway DB: UPDATE verification_sessions \nSET verification_url = $1, request_id = $2, \nverifier_nonce = $3, status = 'authorized', \nauthorized_at = NOW() \nWHERE id = $4 \nRETURNING verification_url, request_id + Kych oauth2 Gateway ->> Kych oauth2 Gateway DB: UPDATE verification_sessions \nSET verification_url = $1, request_id = $2, \nverifier_nonce = $3, status = 'authorized', \nauthorized_at = NOW() \nWHERE id = $4 \nRETURNING verification_url, request_id - OAuth2 Gateway DB -->> OAuth2 Gateway: Updated session - OAuth2 Gateway -->> Client: 200 OK \n{verification_id, verification_url} + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: Updated session + Kych oauth2 Gateway -->> Client: 200 OK \n{verification_id, verification_url} end - end -\ No newline at end of file + end diff --git a/documentation/sequence_diagrams/info_sequence.txt b/documentation/sequence_diagrams/info_sequence.txt @@ -1,29 +1,29 @@ sequenceDiagram participant Client - participant OAuth2 Gateway - participant OAuth2 Gateway DB + participant Kych oauth2 Gateway + participant Kych oauth2 Gateway DB - Client ->> OAuth2 Gateway: GET /info \nAuthorization: Bearer <token> + Client ->> Kych oauth2 Gateway: GET /info \nAuthorization: Bearer <token> - OAuth2 Gateway ->> OAuth2 Gateway: Extract token from \nAuthorization header + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Extract token from \nAuthorization header alt Missing or malformed Authorization header - OAuth2 Gateway -->> Client: 401 UNAUTHORIZED \n{error: 'invalid_token'} + Kych oauth2 Gateway -->> Client: 401 UNAUTHORIZED \n{error: 'invalid_token'} else Valid header format - OAuth2 Gateway ->> OAuth2 Gateway DB: UPDATE access_tokens t \nSET revoked = t.revoked \nFROM verification_sessions s \nWHERE t.session_id = s.id \nAND t.token = $1 \nAND t.expires_at > NOW() \nRETURNING t.revoked, s.status, \ns.verifiable_credential + Kych oauth2 Gateway ->> Kych oauth2 Gateway DB: UPDATE access_tokens t \nSET revoked = t.revoked \nFROM verification_sessions s \nWHERE t.session_id = s.id \nAND t.token = $1 \nAND t.expires_at > NOW() \nRETURNING t.revoked, s.status, \ns.verifiable_credential alt Token not found or expired - OAuth2 Gateway DB -->> OAuth2 Gateway: 0 rows - OAuth2 Gateway -->> Client: 401 UNAUTHORIZED \n{error: 'invalid_token'} + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: 0 rows + Kych oauth2 Gateway -->> Client: 401 UNAUTHORIZED \n{error: 'invalid_token'} else Token found - OAuth2 Gateway DB -->> OAuth2 Gateway: token and session data + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: token and session data - OAuth2 Gateway ->> OAuth2 Gateway: Validate:\n- not revoked\n- status == 'completed' + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Validate:\n- not revoked\n- status == 'completed' alt Invalid token state - OAuth2 Gateway -->> Client: 401 UNAUTHORIZED \n{error: 'invalid_token'} + Kych oauth2 Gateway -->> Client: 401 UNAUTHORIZED \n{error: 'invalid_token'} else Valid token and VC available - OAuth2 Gateway -->> Client: 200 OK \n{verifiable_credential} + Kych oauth2 Gateway -->> Client: 200 OK \n{verifiable_credential} end end - end -\ No newline at end of file + end diff --git a/documentation/sequence_diagrams/notification_sequence.txt b/documentation/sequence_diagrams/notification_sequence.txt @@ -1,40 +1,46 @@ sequenceDiagram participant Swiyu Verifier - participant OAuth2 Gateway - participant OAuth2 Gateway DB - - note over Swiyu Verifier,OAuth2 Gateway DB: Incoming Webhook from Swiyu - - Swiyu Verifier ->> OAuth2 Gateway: POST /notification \n{verification_id, timestamp} - - OAuth2 Gateway ->> OAuth2 Gateway DB: UPDATE verification_sessions s \nSET status = s.status \nFROM clients c \nWHERE s.client_id = c.id \nAND s.request_id = $1 \nRETURNING s.id, s.nonce, s.status, \nc.id AS client_id, c.webhook_url, \nc.verifier_url, c.verifier_management_api_path - + participant Kych oauth2 Gateway + participant Kych oauth2 Gateway DB + + note over Swiyu Verifier,Kych oauth2 Gateway DB: Incoming Webhook from Swiyu + + Swiyu Verifier ->> Kych oauth2 Gateway: POST /notification \n{verification_id, timestamp} + + Kych oauth2 Gateway ->> Kych oauth2 Gateway DB: UPDATE verification_sessions s \nSET status = s.status \nFROM clients c \nWHERE s.client_id = c.id \nAND s.request_id = $1 \nRETURNING s.id, s.nonce, s.status, \nc.id AS client_id, c.webhook_url, \nc.verifier_url, c.verifier_management_api_path + alt DB error or session invalid - OAuth2 Gateway DB -->> OAuth2 Gateway: Error / 0 rows - OAuth2 Gateway ->> OAuth2 Gateway: Log error\n- DB connection failed\n- Session not found\n- Session not authorized\n- Session already processed - OAuth2 Gateway -->> Swiyu Verifier: 200 OK + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: Error / 0 rows + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Log error\n- DB connection failed\n- Session not found\n- Session not authorized\n- Session already processed + Kych oauth2 Gateway -->> Swiyu Verifier: 200 OK else Session found - OAuth2 Gateway DB -->> OAuth2 Gateway: session + client data - - OAuth2 Gateway ->> OAuth2 Gateway: Validate session (status == 'authorized') - + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: session + client data + + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Validate session (status == 'authorized') + alt Session invalid - OAuth2 Gateway ->> OAuth2 Gateway: Log error\n- Session not authorized\n- Session already processed - OAuth2 Gateway -->> Swiyu Verifier: 200 OK + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Log error\n- Session not authorized\n- Session already processed + Kych oauth2 Gateway -->> Swiyu Verifier: 200 OK else Session valid - OAuth2 Gateway ->> Swiyu Verifier: GET verifier_url + verifier_management_api_path + /verification_id - Swiyu Verifier -->> OAuth2 Gateway: {status: 'verified'/'failed', ...} - - OAuth2 Gateway ->> OAuth2 Gateway: generate_authorization_code() - - OAuth2 Gateway ->> OAuth2 Gateway DB: WITH updated AS (\n UPDATE verification_sessions \n SET status = $1, verified_at = NOW() \n WHERE id = $2 RETURNING id\n),\ninserted_code AS (\n INSERT INTO authorization_codes \n (session_id, code, expires_at) \n VALUES ($2, $3, NOW() + INTERVAL '10 minutes') \n RETURNING code\n)\nINSERT INTO notification_pending_webhooks \n(session_id, client_id, url, body, next_attempt) \nSELECT $2, $4, $5, $6, 0 - - alt Operation failed - OAuth2 Gateway ->> OAuth2 Gateway: Log error\n- Verifier fetch failed\n- DB update failed\n- Code generation failed\n- Queue insert failed - else Success - OAuth2 Gateway DB ->> OAuth2 Gateway DB: TRIGGER\nNotifies Worker Thread\nfor Client Webhooks - OAuth2 Gateway DB -->> OAuth2 Gateway: OK + Kych oauth2 Gateway ->> Swiyu Verifier: GET verifier_url + verifier_management_api_path + /verification_id + Swiyu Verifier -->> Kych oauth2 Gateway: {state: 'Success'/'Failed'/'Pending', wallet_response} + + alt Verification pending + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Log info, ignore webhook + Kych oauth2 Gateway -->> Swiyu Verifier: 200 OK + else Verification success or failed + Kych oauth2 Gateway ->> Kych oauth2 Gateway: generate_authorization_code() + + Kych oauth2 Gateway ->> Kych oauth2 Gateway DB: WITH updated_session AS (\n UPDATE verification_sessions \n SET status = $1, verified_at = NOW(),\n verifiable_credential = $5 \n WHERE id = $2 RETURNING id\n)\nINSERT INTO authorization_codes \n(session_id, code, expires_at) \nVALUES ($2, $3, NOW() + INTERVAL '10 minutes') \nRETURNING code + + alt Operation failed + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Log error\n- Verifier fetch failed\n- DB update failed\n- Code generation failed + else Success + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: authorization_code + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Log success + end + Kych oauth2 Gateway -->> Swiyu Verifier: 200 OK end end - OAuth2 Gateway -->> Swiyu Verifier: 200 OK - end -\ No newline at end of file + end + diff --git a/documentation/sequence_diagrams/setup_sequence.txt b/documentation/sequence_diagrams/setup_sequence.txt @@ -1,21 +1,21 @@ sequenceDiagram participant Client - participant OAuth2 Gateway - participant OAuth2 Gateway DB + participant Kych oauth2 Gateway + participant Kych oauth2 Gateway DB - Client ->> OAuth2 Gateway: POST /setup/{client_id}\n{scope: "first_name last_name"} + Client ->> Kych oauth2 Gateway: POST /setup/{client_id}\n{scope: "first_name last_name"} - OAuth2 Gateway ->> OAuth2 Gateway: generate_nonce()\n(256-bit CSPRNG) + Kych oauth2 Gateway ->> Kych oauth2 Gateway: generate_nonce()\n(256-bit CSPRNG) - OAuth2 Gateway ->> OAuth2 Gateway DB: INSERT INTO verification_sessions\n(client_id, nonce, scope, expires_at)\nSELECT c.id, $1, $2, NOW() + INTERVAL '15 minutes'\nFROM clients c WHERE c.client_id = $3\nRETURNING id, nonce, expires_at + Kych oauth2 Gateway ->> Kych oauth2 Gateway DB: INSERT INTO verification_sessions\n(client_id, nonce, scope, expires_at)\nSELECT c.id, $1, $2, NOW() + INTERVAL '15 minutes'\nFROM clients c WHERE c.client_id = $3\nRETURNING id, nonce, expires_at alt Client not found - OAuth2 Gateway DB -->> OAuth2 Gateway: 0 rows - OAuth2 Gateway -->> Client: 404 NOT FOUND\n{error: "client_not_found"} + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: 0 rows + Kych oauth2 Gateway -->> Client: 404 NOT FOUND\n{error: "client_not_found"} else DB error - OAuth2 Gateway DB -->> OAuth2 Gateway: Error - OAuth2 Gateway -->> Client: 500 INTERNAL SERVER ERROR + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: Error + Kych oauth2 Gateway -->> Client: 500 INTERNAL SERVER ERROR else Success - OAuth2 Gateway DB -->> OAuth2 Gateway: session {id, nonce, expires_at} - OAuth2 Gateway -->> Client: 200 OK {nonce} - end -\ No newline at end of file + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: session {id, nonce, expires_at} + Kych oauth2 Gateway -->> Client: 200 OK {nonce} + end diff --git a/documentation/sequence_diagrams/swiyu_taler_sequence_diagram.txt b/documentation/sequence_diagrams/swiyu_taler_sequence_diagram.txt @@ -1,52 +1,59 @@ sequenceDiagram title Swiyu-Taler Interaction - + participant Browser participant TalerWallet participant Exchange - participant Oauth2Gateway + participant Kych oauth2 Gateway participant SwiyuVerifier participant SwiyuWallet - TalerWallet ->> Exchange: Initiate KYC-required operation + TalerWallet ->> Exchange: Initiate KYC-requiring operation Exchange -->> TalerWallet: Send verification link TalerWallet ->> Browser: Open link Browser ->> Exchange: Select verification method (Swiyu) - note over Exchange,Oauth2Gateway: Exchange initiates KYC verification process - Exchange ->> Oauth2Gateway: POST /setup/$CLIENT_ID - Oauth2Gateway -->> Exchange: $NONCE - Exchange ->> Browser: Send /authorize endpoint - - Browser ->> Oauth2Gateway: GET /authorize/$NONCE... - Oauth2Gateway ->> SwiyuVerifier: POST /management/api/verifications - SwiyuVerifier -->> Oauth2Gateway: $VERIFICATION_URL, $REQUEST_ID - Oauth2Gateway -->> Browser: Send $VERIFICATION_URL - - Browser ->> Oauth2Gateway: Poll Verification Status - Browser ->> SwiyuWallet: Open $VERIFICATION_URL + note over Exchange,Kych oauth2 Gateway: Exchange initiates KYC verification process + Exchange ->> Kych oauth2 Gateway: POST /setup/{client_id}\nAuthorization: Bearer $CLIENT_SECRET + Kych oauth2 Gateway -->> Exchange: {nonce: $NONCE} + Exchange ->> Browser: Displays /authorize link to user + + Browser ->> Kych oauth2 Gateway: GET /authorize/{nonce}\n?response_type=code\n&client_id={client_id}\n&redirect_uri={redirect_uri}\n&state={state}\n&scope={scope} + Kych oauth2 Gateway ->> SwiyuVerifier: POST /management/api/verifications\n(Creates verification request for {scope}) + SwiyuVerifier -->> Kych oauth2 Gateway: {verification_url, id, state: PENDING} + Kych oauth2 Gateway -->> Browser: HTML page\n(verification_url, verification_id, state)\nQR code + swiyu deeplink encode verification_url + + + loop Poll until status is "verified" or "failed" + Browser ->> Kych oauth2 Gateway: GET /status/{verification_id}\n?state={state} + Kych oauth2 Gateway -->> Browser: {status: "authorized"} + end + + Browser ->> SwiyuWallet: Open $VERIFICATION_URL\n(scan QR or open swiyu wallet deeplink) SwiyuWallet ->> SwiyuVerifier: GET /oid4vp/api/request-object/{request_id} (DCQL Query) - SwiyuVerifier -->> SwiyuWallet: verification presentation definition + SwiyuVerifier -->> SwiyuWallet: Verification Presentation definition SwiyuWallet ->> SwiyuVerifier: GET verifier_metadata SwiyuVerifier -->> SwiyuWallet: return metadata - SwiyuWallet ->> SwiyuWallet: Grant Permission - SwiyuWallet ->> SwiyuVerifier: POST /oid4vp/api/request-object/{request_id}/response-data (VP Token) - - note over Oauth2Gateway,Exchange: Oauth2Gateway receives webhook and retrieves swiyu wallet response - SwiyuVerifier ->> Oauth2Gateway: POST /notification {verification_id, timestamp} - Oauth2Gateway ->> SwiyuVerifier: GET /management/api/verifications/{verification_id} - SwiyuVerifier -->> Oauth2Gateway: {state: SUCCESS/FAILED, wallet_response} - Oauth2Gateway -->> Browser: Notify verification result - Oauth2Gateway ->> Exchange: POST /oauth2gw/kyc/notify/$CLIENT_ID {status} - - note over Exchange,Oauth2Gateway: Exchange retrieves the final proof (Verifiable Credential) - Exchange ->> Oauth2Gateway: POST /token - Oauth2Gateway -->> Exchange: Access token - Exchange ->> Oauth2Gateway: GET /info (with access token) - Oauth2Gateway ->> SwiyuVerifier: GET /management/api/verifications/{verificationId} - SwiyuVerifier -->> Oauth2Gateway: Send proof (Verifiable Credential) - Oauth2Gateway -->> Exchange: Send proof (in response body) + SwiyuWallet ->> SwiyuWallet: User grants permission + SwiyuWallet ->> SwiyuVerifier: POST /oid4vp/api/request-object/{request_id}/response-data\n(VP Token) + + note over Kych oauth2 Gateway,SwiyuVerifier: Kych oauth2 Gateway receives webhook\nand retrieves wallet response + SwiyuVerifier ->> Kych oauth2 Gateway: POST /notification\n{verification_id, timestamp} + Kych oauth2 Gateway ->> SwiyuVerifier: GET /management/api/verifications/{verification_id} + SwiyuVerifier -->> Kych oauth2 Gateway: {state: SUCCESS/FAILED,\nwallet_response} + + note over Browser,Kych oauth2 Gateway: Browser poll detects completion + Browser ->> Kych oauth2 Gateway: GET /status/{verification_id}\n?state={state} + Kych oauth2 Gateway -->> Browser: {status: "verified",\ncode: $AUTH_CODE,\nredirect_uri} + Browser ->> Exchange: GET {redirect_uri}\n?code={auth_code}\n&state={state} + + note over Exchange,Kych oauth2 Gateway: Exchange retrieves the Verifiable Credential + Exchange ->> Kych oauth2 Gateway: POST /token\nContent-Type: application/x-www-form-urlencoded\ngrant_type=authorization_code\n&code={auth_code}\n&client_id={client_id}\n&client_secret={client_secret} + Kych oauth2 Gateway -->> Exchange: {access_token,\ntoken_type: "Bearer",\nexpires_in} + Exchange ->> Kych oauth2 Gateway: GET /info\nAuthorization: Bearer $ACCESS_TOKEN + Kych oauth2 Gateway -->> Exchange: $VERIFIABLE_CREDENTIAL Exchange -->> TalerWallet: Notify success - TalerWallet ->> Exchange: Retry original operation + TalerWallet ->> Exchange: Retry KYC-requiring original operation + diff --git a/documentation/sequence_diagrams/token_sequence.txt b/documentation/sequence_diagrams/token_sequence.txt @@ -1,41 +1,41 @@ sequenceDiagram participant Client - participant OAuth2 Gateway - participant OAuth2 Gateway DB + participant Kych oauth2 Gateway + participant Kych oauth2 Gateway DB - Client ->> OAuth2 Gateway: POST /token \n{code, grant_type} + Client ->> Kych oauth2 Gateway: POST /token \n{code, grant_type} - OAuth2 Gateway ->> OAuth2 Gateway: Validate grant_type == \n'authorization_code' + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Validate grant_type == \n'authorization_code' alt Invalid grant type - OAuth2 Gateway -->> Client: 400 BAD REQUEST \n{error: 'unsupported_grant_type'} + Kych oauth2 Gateway -->> Client: 400 BAD REQUEST \n{error: 'unsupported_grant_type'} else Valid grant type - OAuth2 Gateway ->> OAuth2 Gateway DB: WITH code_data AS (\n SELECT id, used AS was_already_used, session_id\n FROM authorization_codes\n WHERE code = $1 AND expires_at > NOW()\n FOR UPDATE\n),\nupdated_code AS (\n UPDATE authorization_codes ac\n SET used = TRUE,\n used_at = CASE WHEN NOT ac.used THEN NOW() ELSE ac.used_at END\n FROM code_data cd\n WHERE ac.id = cd.id\n RETURNING ac.id, ac.session_id\n)\nSELECT uc.id AS code_id,\n cd.was_already_used,\n uc.session_id,\n vs.status AS session_status,\n at.token AS existing_token,\n at.expires_at AS token_expires_at\nFROM updated_code uc\nJOIN code_data cd ON uc.id = cd.id\nJOIN verification_sessions vs ON vs.id = uc.session_id\nLEFT JOIN access_tokens at\n ON at.session_id = vs.id AND at.revoked = FALSE + Kych oauth2 Gateway ->> Kych oauth2 Gateway DB: WITH code_data AS (\n SELECT id, used AS was_already_used, session_id\n FROM authorization_codes\n WHERE code = $1 AND expires_at > NOW()\n FOR UPDATE\n),\nupdated_code AS (\n UPDATE authorization_codes ac\n SET used = TRUE,\n used_at = CASE WHEN NOT ac.used THEN NOW() ELSE ac.used_at END\n FROM code_data cd\n WHERE ac.id = cd.id\n RETURNING ac.id, ac.session_id\n)\nSELECT uc.id AS code_id,\n cd.was_already_used,\n uc.session_id,\n vs.status AS session_status,\n at.token AS existing_token,\n at.expires_at AS token_expires_at\nFROM updated_code uc\nJOIN code_data cd ON uc.id = cd.id\nJOIN verification_sessions vs ON vs.id = uc.session_id\nLEFT JOIN access_tokens at\n ON at.session_id = vs.id AND at.revoked = FALSE alt No code found or DB error - OAuth2 Gateway DB -->> OAuth2 Gateway: 0 rows / Error - OAuth2 Gateway -->> Client: Error Response:\n- 400 BAD REQUEST {error: 'invalid_grant'}\n- 500 INTERNAL SERVER ERROR + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: 0 rows / Error + Kych oauth2 Gateway -->> Client: Error Response:\n- 400 BAD REQUEST {error: 'invalid_grant'}\n- 500 INTERNAL SERVER ERROR else Code found - OAuth2 Gateway DB -->> OAuth2 Gateway: code, session, and token data + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: code, session, and token data - OAuth2 Gateway ->> OAuth2 Gateway: Check state + Kych oauth2 Gateway ->> Kych oauth2 Gateway: Check state alt Token already exists (idempotent) - OAuth2 Gateway -->> Client: 200 OK \n{access_token: existing_token, \ntoken_type: 'Bearer', expires_in: 3600} + Kych oauth2 Gateway -->> Client: 200 OK \n{access_token: existing_token, \ntoken_type: 'Bearer', expires_in: 3600} else Invalid state - OAuth2 Gateway -->> Client: 400 BAD REQUEST \n{error: 'invalid_grant'}\n- Code already used\n- Session not verified + Kych oauth2 Gateway -->> Client: 400 BAD REQUEST \n{error: 'invalid_grant'}\n- Code already used\n- Session not verified else Valid - create token - OAuth2 Gateway ->> OAuth2 Gateway: generate_access_token() + Kych oauth2 Gateway ->> Kych oauth2 Gateway: generate_access_token() - OAuth2 Gateway ->> OAuth2 Gateway DB: WITH updated AS (\n UPDATE verification_sessions \n SET status = 'completed', completed_at = NOW() \n WHERE id = $1 RETURNING id\n)\nINSERT INTO access_tokens \n(session_id, token, expires_at) \nVALUES ($1, $2, NOW() + INTERVAL '1 hour') \nRETURNING token, expires_at + Kych oauth2 Gateway ->> Kych oauth2 Gateway DB: WITH updated AS (\n UPDATE verification_sessions \n SET status = 'completed', completed_at = NOW() \n WHERE id = $1 RETURNING id\n)\nINSERT INTO access_tokens \n(session_id, token, expires_at) \nVALUES ($1, $2, NOW() + INTERVAL '1 hour') \nRETURNING token, expires_at alt Error - OAuth2 Gateway DB -->> OAuth2 Gateway: Error - OAuth2 Gateway -->> Client: 500 INTERNAL SERVER ERROR + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: Error + Kych oauth2 Gateway -->> Client: 500 INTERNAL SERVER ERROR else Success - OAuth2 Gateway DB -->> OAuth2 Gateway: token, expires_at - OAuth2 Gateway -->> Client: 200 OK \n{access_token, token_type: 'Bearer', expires_in: 3600} + Kych oauth2 Gateway DB -->> Kych oauth2 Gateway: token, expires_at + Kych oauth2 Gateway -->> Client: 200 OK \n{access_token, token_type: 'Bearer', expires_in: 3600} end end end - end -\ No newline at end of file + end diff --git a/kych_oauth2_gateway/Cargo.toml b/kych_oauth2_gateway/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "kych" +version = "0.0.1" +edition = "2024" + +[lib] +name = "kych_oauth2_gateway_lib" +path = "src/lib.rs" + +[[bin]] +name = "kych-oauth2-gateway" +path = "src/main.rs" + +[[bin]] +name = "kych-oauth2-gateway-webhook-worker" +path = "src/bin/webhook_worker.rs" + +[[bin]] +name = "kych-client-management" +path = "src/bin/client_management_cli.rs" + +[dependencies] +# Web framework +axum = "0.8.6" +axum-test = "18.1.0" +tokio = { version = "1.48.0", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6.6", features = ["trace", "fs"] } + +# Serialization +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" + +# HTTP client +reqwest = { version = "0.12", features = ["json"] } + +# Configuration +rust-ini = "0.21.3" +clap = { version = "4.5.49", features = ["derive"] } + +# Utilities +uuid = { version = "1.18.1", features = ["v4", "serde"] } +chrono = { version = "0.4.42", features = ["serde"] } + +# Logging +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", features = ["env-filter", "local-time"] } + +# Error handling +anyhow = "1.0.100" + +# Environment +dotenvy = "0.15" + +# Cryptography +rand = "0.8.5" +bcrypt = "0.15" +base64 = "0.22.1" + +# Database +sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] } + +# Templates +askama = "0.12" + +[dev-dependencies] +tempfile = "3.8" +wiremock = "0.6" +serial_test = "3.2.0" diff --git a/oauth2_gateway/clients.conf.example b/kych_oauth2_gateway/clients.conf.example diff --git a/oauth2_gateway/config.ini.example b/kych_oauth2_gateway/config.ini.example diff --git a/oauth2_gateway/env.example b/kych_oauth2_gateway/env.example diff --git a/kych_oauth2_gateway/js/qrcode.min.js b/kych_oauth2_gateway/js/qrcode.min.js @@ -0,0 +1 @@ +var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c<a.length&&0==a[c];)c++;this.num=new Array(a.length-c+b);for(var d=0;d<a.length-c;d++)this.num[d]=a[d+c]}function j(a,b){this.totalCount=a,this.dataCount=b}function k(){this.buffer=[],this.length=0}function m(){return"undefined"!=typeof CanvasRenderingContext2D}function n(){var a=!1,b=navigator.userAgent;return/android/i.test(b)&&(a=!0,aMat=b.toString().match(/android ([0-9]\.[0-9])/i),aMat&&aMat[1]&&(a=parseFloat(aMat[1]))),a}function r(a,b){for(var c=1,e=s(a),f=0,g=l.length;g>=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d<this.moduleCount;d++){this.modules[d]=new Array(this.moduleCount);for(var e=0;e<this.moduleCount;e++)this.modules[d][e]=null}this.setupPositionProbePattern(0,0),this.setupPositionProbePattern(this.moduleCount-7,0),this.setupPositionProbePattern(0,this.moduleCount-7),this.setupPositionAdjustPattern(),this.setupTimingPattern(),this.setupTypeInfo(a,c),this.typeNumber>=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f<this.modules.length;f++)for(var g=f*e,h=0;h<this.modules[f].length;h++){var i=h*e,j=this.modules[f][h];j&&(d.beginFill(0,100),d.moveTo(i,g),d.lineTo(i+e,g),d.lineTo(i+e,g+e),d.lineTo(i,g+e),d.endFill())}return d},setupTimingPattern:function(){for(var a=8;a<this.moduleCount-8;a++)null==this.modules[a][6]&&(this.modules[a][6]=0==a%2);for(var b=8;b<this.moduleCount-8;b++)null==this.modules[6][b]&&(this.modules[6][b]=0==b%2)},setupPositionAdjustPattern:function(){for(var a=f.getPatternPosition(this.typeNumber),b=0;b<a.length;b++)for(var c=0;c<a.length;c++){var d=a[b],e=a[c];if(null==this.modules[d][e])for(var g=-2;2>=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g<a.length&&(j=1==(1&a[g]>>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h<d.length;h++){var i=d[h];g.put(i.mode,4),g.put(i.getLength(),f.getLengthInBits(i.mode,a)),i.write(g)}for(var l=0,h=0;h<e.length;h++)l+=e[h].dataCount;if(g.getLengthInBits()>8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j<b.length;j++){var k=b[j].dataCount,l=b[j].totalCount-k;d=Math.max(d,k),e=Math.max(e,l),g[j]=new Array(k);for(var m=0;m<g[j].length;m++)g[j][m]=255&a.buffer[m+c];c+=k;var n=f.getErrorCorrectPolynomial(l),o=new i(g[j],n.getLength()-1),p=o.mod(n);h[j]=new Array(n.getLength()-1);for(var m=0;m<h[j].length;m++){var q=m+p.getLength()-h[j].length;h[j][m]=q>=0?p.get(q):0}}for(var r=0,m=0;m<b.length;m++)r+=b[m].totalCount;for(var s=new Array(r),t=0,m=0;d>m;m++)for(var j=0;j<b.length;j++)m<g[j].length&&(s[t++]=g[j][m]);for(var m=0;e>m;m++)for(var j=0;j<b.length;j++)m<h[j].length&&(s[t++]=h[j][m]);return s};for(var c={MODE_NUMBER:1,MODE_ALPHA_NUM:2,MODE_8BIT_BYTE:4,MODE_KANJI:8},d={L:1,M:0,Q:3,H:2},e={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7},f={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:1335,G18:7973,G15_MASK:21522,getBCHTypeInfo:function(a){for(var b=a<<10;f.getBCHDigit(b)-f.getBCHDigit(f.G15)>=0;)b^=f.G15<<f.getBCHDigit(b)-f.getBCHDigit(f.G15);return(a<<10|b)^f.G15_MASK},getBCHTypeNumber:function(a){for(var b=a<<12;f.getBCHDigit(b)-f.getBCHDigit(f.G18)>=0;)b^=f.G18<<f.getBCHDigit(b)-f.getBCHDigit(f.G18);return a<<12|b},getBCHDigit:function(a){for(var b=0;0!=a;)b++,a>>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<<h;for(var h=8;256>h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;c<this.getLength();c++)for(var d=0;d<a.getLength();d++)b[c+d]^=g.gexp(g.glog(this.get(c))+g.glog(a.get(d)));return new i(b,0)},mod:function(a){if(this.getLength()-a.getLength()<0)return this;for(var b=g.glog(this.get(0))-g.glog(a.get(0)),c=new Array(this.getLength()),d=0;d<this.getLength();d++)c[d]=this.get(d);for(var d=0;d<a.getLength();d++)c[d]^=g.gexp(g.glog(a.get(d))+b);return new i(c,0).mod(a)}},j.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]],j.getRSBlocks=function(a,b){var c=j.getRsBlockTable(a,b);if(void 0==c)throw new Error("bad rs block @ typeNumber:"+a+"/errorCorrectLevel:"+b);for(var d=c.length/3,e=[],f=0;d>f;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=['<table style="border:0;border-collapse:collapse;">'],h=0;d>h;h++){g.push("<tr>");for(var i=0;d>i;i++)g.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:'+e+"px;height:"+f+"px;background-color:"+(a.isDark(h,i)?b.colorDark:b.colorLight)+';"></td>');g.push("</tr>")}g.push("</table>"),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); +\ No newline at end of file diff --git a/oauth2_gateway/oauth2_gatewaydb/drop.sql b/kych_oauth2_gateway/oauth2_gatewaydb/drop.sql diff --git a/oauth2_gateway/oauth2_gatewaydb/install_db.sh b/kych_oauth2_gateway/oauth2_gatewaydb/install_db.sh diff --git a/oauth2_gateway/oauth2_gatewaydb/oauth2gw-0001.sql b/kych_oauth2_gateway/oauth2_gatewaydb/oauth2gw-0001.sql diff --git a/oauth2_gateway/oauth2_gatewaydb/uninstall_db.sh b/kych_oauth2_gateway/oauth2_gatewaydb/uninstall_db.sh diff --git a/oauth2_gateway/oauth2_gatewaydb/versioning.sql b/kych_oauth2_gateway/oauth2_gatewaydb/versioning.sql diff --git a/oauth2_gateway/openapi.yaml b/kych_oauth2_gateway/openapi.yaml diff --git a/kych_oauth2_gateway/src/bin/client_management_cli.rs b/kych_oauth2_gateway/src/bin/client_management_cli.rs @@ -0,0 +1,420 @@ +//! OAuth2 Gateway CLI +//! +//! Command-line tool for managing OAuth2 Gateway clients. +//! +//! Set DATABASE_URL environment variable to connect to the database. +//! Usage: +//! client-mgmt client list +//! client-mgmt client show <client_id> +//! client-mgmt client create --client-id <id> --secret <secret> ... +//! client-mgmt client update <client_id> --webhook-url <url> +//! client-mgmt client delete <client_id> + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use ini::Ini; +use kych_oauth2_gateway_lib::db; +use std::env; +use std::collections::HashSet; + +#[derive(Parser, Debug)] +#[command(name = "client-mgmt")] + +#[command(version)] +#[command(about = "OAuth2 Gateway client management CLI")] +#[command(after_help = "Environment variables:\n DATABASE_URL PostgreSQL connection string (required)")] +struct Args { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand, Debug)] +enum Commands { + List, + + Show { + client_id: String, + }, + + Create { + /// Unique client identifier + #[arg(long)] + client_id: String, + + /// Client secret (stored as hash) + #[arg(long)] + secret: String, + + /// Webhook URL for notifications + #[arg(long)] + webhook_url: String, + + /// Swiyu verifier base URL + #[arg(long)] + verifier_url: String, + + /// Verifier management API path (default: /management/api/verifications) + #[arg(long)] + verifier_api_path: Option<String>, + + /// Default redirect URI for OAuth2 flow + #[arg(long)] + redirect_uri: Option<String>, + + /// Comma-separated list of accepted issuer DIDs + #[arg(long)] + accepted_issuer_dids: Option<String>, + }, + + Update { + client_id: String, + + #[arg(long)] + webhook_url: Option<String>, + + #[arg(long)] + verifier_url: Option<String>, + + #[arg(long)] + verifier_api_path: Option<String>, + + #[arg(long)] + redirect_uri: Option<String>, + + #[arg(long)] + accepted_issuer_dids: Option<String>, + }, + + /// Sync clients from a configuration file + Sync { + /// Path to clients.conf file + config_file: String, + + /// Remove clients not in config file + #[arg(long)] + prune: bool, + }, + + /// Delete a client (WARNING: cascades to all sessions) + Delete { + client_id: String, + + #[arg(long, short = 'y')] + yes: bool, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Load .env (ignore if missing) + let _ = dotenvy::dotenv(); + + let args = Args::parse(); + + let database_url = env::var("DATABASE_URL") + .context("DATABASE_URL environment variable not set")?; + + let pool = db::create_pool(&database_url) + .await + .context("Failed to connect to database")?; + + match args.command { + Commands::List => cmd_list_clients(&pool).await?, + Commands::Show { client_id } => cmd_show_client(&pool,&client_id).await?, + Commands::Create { + client_id, + secret, + webhook_url, + verifier_url, + verifier_api_path, + redirect_uri, + accepted_issuer_dids, + } => { + cmd_create_client( + &pool, + &client_id, + &secret, + &webhook_url, + &verifier_url, + verifier_api_path.as_deref(), + redirect_uri.as_deref(), + accepted_issuer_dids.as_deref(), + ) + .await? + } + Commands::Update { + client_id, + webhook_url, + verifier_url, + verifier_api_path, + redirect_uri, + accepted_issuer_dids, + } => { + cmd_update_client( + &pool, + &client_id, + webhook_url.as_deref(), + verifier_url.as_deref(), + verifier_api_path.as_deref(), + redirect_uri.as_deref(), + accepted_issuer_dids.as_deref(), + ) + .await? + } + Commands::Sync { config_file, prune } => { + cmd_sync_clients(&pool, &config_file, prune).await? + } + Commands::Delete { client_id, yes } => { + cmd_delete_client(&pool, &client_id, yes).await? + } + } + + Ok(()) +} + +fn print_client_details(client: &db::clients::Client) { + use chrono::Local; + + println!("{}", "=".repeat(60)); + println!("UUID: {}", client.id); + println!("Client ID: {}", client.client_id); + println!("Secret Hash: {}...", &client.secret_hash[..20.min(client.secret_hash.len())]); + println!("Webhook URL: {}", client.webhook_url); + println!("Verifier URL: {}", client.verifier_url); + println!("Verifier API Path: {}", client.verifier_management_api_path); + println!("Redirect URI: {}", client.redirect_uri.as_deref().unwrap_or("(not set)")); + println!("Accepted Issuer DIDs: {}", client.accepted_issuer_dids.as_deref().unwrap_or("(not set)")); + println!("Created: {}", client.created_at.with_timezone(&Local)); + println!("Updated: {}", client.updated_at.with_timezone(&Local)); +} + +async fn cmd_list_clients(pool: &sqlx::PgPool) -> Result<()> { + let clients = db::clients::list_clients(pool).await?; + + if clients.is_empty() { + println!("No clients registered."); + return Ok(()); + } + + let total = clients.len(); + + for (i, client) in clients.iter().enumerate() { + if i > 0 { + println!(); + } + print_client_details(&client); + } + + println!(); + println!("{}", "=".repeat(60)); + println!("Total clients: {}", total); + + Ok(()) +} + +async fn cmd_show_client(pool: &sqlx::PgPool, client_id: &str) -> Result<()> { + let client = db::clients::get_client_by_id(pool, client_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; + + print_client_details(&client); + + Ok(()) +} + +async fn cmd_create_client( + pool: &sqlx::PgPool, + client_id: &str, + secret: &str, + webhook_url: &str, + verifier_url: &str, + verifier_api_path: Option<&str>, + redirect_uri: Option<&str>, + accepted_issuer_dids: Option<&str>, +) -> Result<()> { + let client = db::clients::register_client( + pool, + client_id, + secret, + webhook_url, + verifier_url, + verifier_api_path, + redirect_uri, + accepted_issuer_dids, + ) + .await + .context("Failed to create client")?; + + println!("Client created successfully."); + println!(); + print_client_details(&client); + + Ok(()) +} + +async fn cmd_update_client( + pool: &sqlx::PgPool, + client_id: &str, + webhook_url: Option<&str>, + verifier_url: Option<&str>, + verifier_api_path: Option<&str>, + redirect_uri: Option<&str>, + accepted_issuer_dids: Option<&str>, +) -> Result<()> { + if webhook_url.is_none() && verifier_url.is_none() && verifier_api_path.is_none() + && redirect_uri.is_none() && accepted_issuer_dids.is_none() { + anyhow::bail!("No fields to update. Specify at least one of: --webhook-url, --verifier-url, --verifier-api-path, --redirect-uri, --accepted-issuer-dids"); + } + + let client = db::clients::get_client_by_id(pool, client_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; + + let updated = db::clients::update_client( + pool, + client.id, + webhook_url, + verifier_url, + verifier_api_path, + redirect_uri, + accepted_issuer_dids, + ) + .await + .context("Failed to update client")?; + + println!("Client updated successfully."); + println!(); + print_client_details(&updated); + + Ok(()) +} + +async fn cmd_delete_client(pool: &sqlx::PgPool, client_id: &str, skip_confirm: bool) -> Result<()> { + let client = db::clients::get_client_by_id(pool, client_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; + + if !skip_confirm { + println!("WARNING: This will delete client '{}' and ALL associated data:", client_id); + println!(" - All sessions"); + println!(" - All tokens"); + println!(" - All pending webhooks"); + println!(); + print!("Type 'yes' to confirm: "); + + use std::io::{self, Write}; + io::stdout().flush()?; + + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + + if input.trim() != "yes" { + println!("Aborted."); + return Ok(()); + } + } + + let deleted = db::clients::delete_client(pool, client.id).await?; + + if deleted { + println!("Client '{}' deleted successfully.", client_id); + } else { + println!("Client not found (may have been deleted already)."); + } + + Ok(()) +} + +async fn cmd_sync_clients(pool: &sqlx::PgPool, config_file: &str, prune: bool) -> Result<()> { + println!("Loading clients from: {}", config_file); + + let ini = Ini::load_from_file(config_file) + .context("Failed to load configuration file")?; + + let mut synced_client_ids = HashSet::new(); + let mut created_count = 0; + let mut updated_count = 0; + + for (section_name, properties) in ini.iter() { + let section_name = match section_name { + Some(name) => name, + None => continue, + }; + + println!("\nProcessing section: [{}]", section_name); + + let client_id = properties.get("client_id") + .ok_or_else(|| anyhow::anyhow!("Missing client_id in section [{}]", section_name))?; + let client_secret = properties.get("client_secret") + .ok_or_else(|| anyhow::anyhow!("Missing client_secret in section [{}]", section_name))?; + let webhook_url = properties.get("webhook_url") + .ok_or_else(|| anyhow::anyhow!("Missing webhook_url in section [{}]", section_name))?; + let verifier_url = properties.get("verifier_url") + .ok_or_else(|| anyhow::anyhow!("Missing verifier_url in section [{}]", section_name))?; + + let verifier_api_path = properties.get("verifier_management_api_path"); + let redirect_uri = properties.get("redirect_uri"); + let accepted_issuer_dids = properties.get("accepted_issuer_dids"); + + synced_client_ids.insert(client_id.to_string()); + + let existing_client = db::clients::get_client_by_id(pool, client_id).await?; + + match existing_client { + Some(existing) => { + println!(" Client '{}' already exists, updating...", client_id); + db::clients::update_client( + pool, + existing.id, + Some(webhook_url), + Some(verifier_url), + verifier_api_path, + redirect_uri, + accepted_issuer_dids, + ) + .await + .context(format!("Failed to update client '{}'", client_id))?; + updated_count += 1; + println!(" Updated client '{}'", client_id); + } + None => { + println!(" Creating new client '{}'...", client_id); + db::clients::register_client( + pool, + client_id, + client_secret, + webhook_url, + verifier_url, + verifier_api_path, + redirect_uri, + accepted_issuer_dids, + ) + .await + .context(format!("Failed to create client '{}'", client_id))?; + created_count += 1; + println!(" Created client '{}'", client_id); + } + } + } + + if prune { + println!("\nPruning clients not in configuration file..."); + let all_clients = db::clients::list_clients(pool).await?; + let mut pruned_count = 0; + + for client in all_clients { + if !synced_client_ids.contains(&client.client_id) { + println!(" Deleting client '{}'...", client.client_id); + db::clients::delete_client(pool, client.id).await?; + pruned_count += 1; + } + } + println!("Pruned {} client(s)", pruned_count); + } + + println!("\nSync complete:"); + println!(" Created: {}", created_count); + println!(" Updated: {}", updated_count); + + Ok(()) +} diff --git a/kych_oauth2_gateway/src/bin/webhook_worker.rs b/kych_oauth2_gateway/src/bin/webhook_worker.rs @@ -0,0 +1,102 @@ +//! Webhook worker daemon binary +//! +//! Background process that delivers webhooks to client endpoints. +//! Uses PostgreSQL NOTIFY/LISTEN for event-driven wake-up. +//! +//! Usage: +//! webhook-worker -c config.ini # Run normally +//! webhook-worker -c config.ini -t # Test mode (exit when idle) + +use kych_oauth2_gateway_lib::{config::Config, db, worker}; +use anyhow::Result; +use clap::Parser; +use tokio::signal; +use tokio::sync::watch; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Parser, Debug)] +#[command(name = "webhook-worker")] +#[command(version)] +#[command(about = "Background process that executes webhooks")] +struct Args { + #[arg(short = 'c', long = "config", value_name = "FILE")] + config: String, + + #[arg(short = 't', long = "test")] + test_mode: bool, + + #[arg(short = 'L', long = "log-level", value_name = "LEVEL", default_value = "INFO")] + log_level: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + let level = args.log_level.to_lowercase(); + let filter = format!( + "kych_webhook_worker={},kych_oauth2_gateway_lib={},tower_http={},sqlx=warn", + level, level, level + ); + + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| filter.into()), + ) + .with( + tracing_subscriber::fmt::layer() + .compact() + .with_ansi(false) + .with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()), + ) + .init(); + + tracing::info!("Starting webhook worker v{}", env!("CARGO_PKG_VERSION")); + tracing::info!("Loading configuration from: {}", args.config); + + let config = Config::from_file(&args.config)?; + + tracing::info!("Connecting to database: {}", config.database.url); + let pool = db::create_pool(&config.database.url).await?; + + // Set up shutdown signal handling + let (shutdown_tx, shutdown_rx) = watch::channel(false); + + // Spawn signal handler task + tokio::spawn(async move { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("Failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + tracing::info!("Received Ctrl+C, initiating shutdown"); + } + _ = terminate => { + tracing::info!("Received SIGTERM, initiating shutdown"); + } + } + + let _ = shutdown_tx.send(true); + }); + + // Run the worker + worker::run_worker(pool, &config.webhook_worker, args.test_mode, shutdown_rx).await?; + + tracing::info!("Webhook worker exited cleanly"); + Ok(()) +} diff --git a/oauth2_gateway/src/config.rs b/kych_oauth2_gateway/src/config.rs diff --git a/oauth2_gateway/src/crypto.rs b/kych_oauth2_gateway/src/crypto.rs diff --git a/oauth2_gateway/src/db/authorization_codes.rs b/kych_oauth2_gateway/src/db/authorization_codes.rs diff --git a/oauth2_gateway/src/db/clients.rs b/kych_oauth2_gateway/src/db/clients.rs diff --git a/oauth2_gateway/src/db/mod.rs b/kych_oauth2_gateway/src/db/mod.rs diff --git a/oauth2_gateway/src/db/notification_webhooks.rs b/kych_oauth2_gateway/src/db/notification_webhooks.rs diff --git a/kych_oauth2_gateway/src/db/sessions.rs b/kych_oauth2_gateway/src/db/sessions.rs @@ -0,0 +1,369 @@ +// Database operations for verification_sessions table + +use sqlx::PgPool; +use anyhow::Result; +use uuid::Uuid; +use chrono::{DateTime, Utc}; + +/// Status of a verification session +#[derive(Debug, Clone, sqlx::Type, serde::Serialize, serde::Deserialize, PartialEq)] +#[sqlx(type_name = "varchar")] +pub enum SessionStatus { + #[sqlx(rename = "pending")] + Pending, + #[sqlx(rename = "authorized")] + Authorized, + #[sqlx(rename = "verified")] + Verified, + #[sqlx(rename = "completed")] + Completed, + #[sqlx(rename = "expired")] + Expired, + #[sqlx(rename = "failed")] + Failed, +} + +/// Verification session record used in /setup endpoint +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct VerificationSession { + pub id: Uuid, + pub client_id: Uuid, + pub nonce: String, + pub scope: String, + pub redirect_uri: Option<String>, + pub state: Option<String>, + pub verification_url: Option<String>, + pub request_id: Option<String>, + pub verifier_nonce: Option<String>, + pub status: SessionStatus, + pub created_at: DateTime<Utc>, + pub authorized_at: Option<DateTime<Utc>>, + pub verified_at: Option<DateTime<Utc>>, + pub completed_at: Option<DateTime<Utc>>, + pub failed_at: Option<DateTime<Utc>>, + pub expires_at: DateTime<Utc>, +} + +/// Authorize record data used in /authorize endpoint +#[derive(Debug, Clone)] +pub struct AuthorizeSessionData { + // Session fields + pub session_id: Uuid, + pub status: SessionStatus, + pub expires_at: DateTime<Utc>, + pub scope: String, + pub redirect_uri: Option<String>, + pub state: Option<String>, + pub verification_url: Option<String>, + pub verification_deeplink: Option<String>, + pub request_id: Option<String>, + pub verifier_nonce: Option<String>, + // Client fields + pub verifier_url: String, + pub verifier_management_api_path: String, + pub allowed_redirect_uris: Option<String>, +} + +/// Notification record data used in /notification webhook endpoint +#[derive(Debug, Clone)] +pub struct NotificationSessionData { + // Session fields + pub session_id: Uuid, + pub nonce: String, + pub status: SessionStatus, + pub redirect_uri: Option<String>, + pub state: Option<String>, + // Client fields + pub client_id: Uuid, + pub webhook_url: String, + pub verifier_url: String, + pub verifier_management_api_path: String, +} + +#[derive(Debug, Clone)] +pub struct StatusSessionData { + pub session_id: Uuid, + pub status: SessionStatus, + pub state: Option<String>, + pub redirect_uri: Option<String>, + pub authorization_code: Option<String>, +} + +/// Create a new verification session +/// +/// Returns None if client_id doesn't exist +/// +/// Used by the /setup endpoint +pub async fn create_session( + pool: &PgPool, + client_id: &str, + nonce: &str, + expires_in_minutes: i64, +) -> Result<Option<VerificationSession>> { + let session = sqlx::query_as::<_, VerificationSession>( + r#" + INSERT INTO oauth2gw.verification_sessions (client_id, nonce, scope, + expires_at, status) + SELECT c.id, $1, '', NOW() + $2 * INTERVAL '1 minute', 'pending' + FROM oauth2gw.clients c + WHERE c.client_id = $3 + RETURNING + id, client_id, nonce, scope, redirect_uri, state, verification_url, + request_id, verifier_nonce, status, created_at, authorized_at, + verified_at, completed_at, failed_at, expires_at + "# + ) + .bind(nonce) + .bind(expires_in_minutes) + .bind(client_id) + .fetch_optional(pool) + .await?; + + Ok(session) +} + +/// Fetch session and client data for /authorize endpoint +/// +/// Returns all data needed for backend validation and idempotent responses. +/// Updates the session scope with the provided scope parameter. +/// Does NOT validate redirect_uri - caller must check against allowed_redirect_uris. +/// +/// Used by the /authorize endpoint +pub async fn get_session_for_authorize( + pool: &PgPool, + nonce: &str, + client_id: &str, + scope: &str, + redirect_uri: &str, + state: &str, +) -> Result<Option<AuthorizeSessionData>> { + let result = sqlx::query( + r#" + UPDATE oauth2gw.verification_sessions s + SET scope = $3, redirect_uri = $4, state = $5 + FROM oauth2gw.clients c + WHERE s.client_id = c.id + AND s.nonce = $1 + AND c.client_id = $2 + RETURNING + s.id AS session_id, + s.status, + s.expires_at, + s.scope, + s.redirect_uri, + s.state, + s.verification_url, + s.verification_deeplink, + s.request_id, + s.verifier_nonce, + c.verifier_url, + c.verifier_management_api_path, + c.redirect_uri AS allowed_redirect_uris + "# + ) + .bind(nonce) + .bind(client_id) + .bind(scope) + .bind(redirect_uri) + .bind(state) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row: sqlx::postgres::PgRow| { + use sqlx::Row; + AuthorizeSessionData { + session_id: row.get("session_id"), + status: row.get("status"), + expires_at: row.get("expires_at"), + scope: row.get("scope"), + redirect_uri: row.get("redirect_uri"), + state: row.get("state"), + verification_url: row.get("verification_url"), + verification_deeplink: row.get("verification_deeplink"), + request_id: row.get("request_id"), + verifier_nonce: row.get("verifier_nonce"), + verifier_url: row.get("verifier_url"), + verifier_management_api_path: row.get("verifier_management_api_path"), + allowed_redirect_uris: row.get("allowed_redirect_uris"), + } + })) +} + +/// Fetch session and client data for /notification webhook +/// +/// Returns all data needed for backend validation and client notification. +/// +/// Used by the /notification endpoint (incoming webhook from Swiyu) +pub async fn get_session_for_notification( + pool: &PgPool, + request_id: &str, +) -> Result<Option<NotificationSessionData>> { + let result = sqlx::query( + r#" + UPDATE oauth2gw.verification_sessions s + SET status = s.status + FROM oauth2gw.clients c + WHERE s.client_id = c.id + AND s.request_id = $1 + RETURNING + s.id AS session_id, + s.nonce, + s.status, + s.redirect_uri, + s.state, + c.id AS client_id, + c.webhook_url, + c.verifier_url, + c.verifier_management_api_path + "# + ) + .bind(request_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row: sqlx::postgres::PgRow| { + use sqlx::Row; + NotificationSessionData { + session_id: row.get("session_id"), + nonce: row.get("nonce"), + status: row.get("status"), + redirect_uri: row.get("redirect_uri"), + state: row.get("state"), + client_id: row.get("client_id"), + webhook_url: row.get("webhook_url"), + verifier_url: row.get("verifier_url"), + verifier_management_api_path: row.get("verifier_management_api_path"), + } + })) +} + +pub async fn get_session_for_status( + pool: &PgPool, + request_id: &str, +) -> Result<Option<StatusSessionData>> { + let result = sqlx::query( + r#" + SELECT + s.id AS session_id, + s.status, + s.state, + s.redirect_uri, + ac.code AS authorization_code + FROM oauth2gw.verification_sessions s + LEFT JOIN oauth2gw.authorization_codes ac + ON ac.session_id = s.id + WHERE s.request_id = $1 + "# + ) + .bind(request_id) + .fetch_optional(pool) + .await?; + + Ok(result.map(|row: sqlx::postgres::PgRow| { + use sqlx::Row; + StatusSessionData { + session_id: row.get("session_id"), + status: row.get("status"), + state: row.get("state"), + redirect_uri: row.get("redirect_uri"), + authorization_code: row.get("authorization_code"), + } + })) +} + +/// Data returned after updating session to authorized +#[derive(Debug, Clone)] +pub struct AuthorizedSessionResult { + pub request_id: String, + pub verification_url: String, +} + +/// Update session to authorized with verifier response data +/// +/// Called after successful POST to Swiyu Verifier. +/// Sets status to 'authorized' and stores verification_url, request_id, verifier_nonce. +/// Returns the request_id (verification_id) and verification_url. +/// +/// Used by the /authorize endpoint +pub async fn update_session_authorized( + pool: &PgPool, + session_id: Uuid, + verification_url: &str, + verification_deeplink: Option<&str>, + request_id: &str, + verifier_nonce: Option<&str>, +) -> Result<AuthorizedSessionResult> { + let result = sqlx::query( + r#" + UPDATE oauth2gw.verification_sessions + SET status = 'authorized', + verification_url = $1, + verification_deeplink = $2, + request_id = $3, + verifier_nonce = $4, + authorized_at = NOW() + WHERE id = $5 + RETURNING request_id, verification_url + "# + ) + .bind(verification_url) + .bind(verification_deeplink) + .bind(request_id) + .bind(verifier_nonce) + .bind(session_id) + .fetch_one(pool) + .await?; + + use sqlx::Row; + Ok(AuthorizedSessionResult { + request_id: result.get("request_id"), + verification_url: result.get("verification_url"), + }) +} + +/// Atomically update session to verified and create authorization code +/// +/// Returns the generated authorization code on success. +pub async fn verify_session_and_queue_notification( + pool: &PgPool, + session_id: Uuid, + status: SessionStatus, + authorization_code: &str, + code_expires_in_minutes: i64, + _client_id: Uuid, + _webhook_url: &str, + _webhook_body: &str, + verifiable_credential: Option<&serde_json::Value>, +) -> Result<String> { + let timestamp_field = match status { + SessionStatus::Verified => "verified_at", + SessionStatus::Failed => "failed_at", + _ => return Err(anyhow::anyhow!("Invalid status for notification: must be Verified or Failed")), + }; + + let query = format!( + r#" + WITH updated_session AS ( + UPDATE oauth2gw.verification_sessions + SET status = $1, {} = NOW(), verifiable_credential = $5 + WHERE id = $2 + RETURNING id + ) + INSERT INTO oauth2gw.authorization_codes (session_id, code, expires_at) + VALUES ($2, $3, NOW() + $4 * INTERVAL '1 minute') + RETURNING code + "#, + timestamp_field + ); + + let code = sqlx::query_scalar::<_, String>(&query) + .bind(status) + .bind(session_id) + .bind(authorization_code) + .bind(code_expires_in_minutes) + .bind(verifiable_credential) + .fetch_one(pool) + .await?; + + Ok(code) +} diff --git a/oauth2_gateway/src/db/tokens.rs b/kych_oauth2_gateway/src/db/tokens.rs diff --git a/kych_oauth2_gateway/src/handlers.rs b/kych_oauth2_gateway/src/handlers.rs @@ -0,0 +1,997 @@ +use axum::{ + Json, + extract::{Path, Query, State}, + http::{StatusCode, header}, + response::IntoResponse, + Form, +}; +use chrono::Utc; +use serde_json::json; + +use crate::{crypto, db::sessions::SessionStatus, models::*, state::AppState}; + +// Health check endpoint +pub async fn health_check() -> impl IntoResponse { + tracing::info!("Received Health Request"); + Json(json!({ + "status": "healthy", + "service": "oauth2-gateway", + })) +} + +// POST /setup/{clientId} +pub async fn setup( + State(state): State<AppState>, + Path(client_id): Path<String>, + headers: axum::http::HeaderMap, +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { + tracing::info!("Setup request for client: {}", client_id); + + let auth_header = headers + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()); + + let bearer_token = match auth_header { + Some(h) if h.starts_with("Bearer ") => &h[7..], + _ => { + tracing::warn!( + "Missing or malformed Authorization header for client: {}", + client_id + ); + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new("unauthorized")), + )); + } + }; + + let secret_hash = crate::db::clients::get_client_secret_hash(&state.pool, &client_id) + .await + .map_err(|e| { + tracing::error!("DB error fetching client secret: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + let secret_hash = match secret_hash { + Some(hash) => hash, + None => { + tracing::warn!("Client not found: {}", client_id); + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new("unauthorized")), + )); + } + }; + + let is_valid = bcrypt::verify(bearer_token, &secret_hash).map_err(|e| { + tracing::error!("Bcrypt verification error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + if !is_valid { + tracing::warn!("Invalid bearer token for client: {}", client_id); + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new("unauthorized")), + )); + } + + let nonce = crypto::generate_nonce(state.config.crypto.nonce_bytes); + + tracing::debug!("Generated nonce: {}", nonce); + + let session = crate::db::sessions::create_session(&state.pool, &client_id, &nonce, 15) + .await + .map_err(|e| { + tracing::error!("Failed to create session: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + let session = match session { + Some(s) => s, + None => { + tracing::warn!("Client not found: {}", client_id); + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse::new("client_not_found")), + )); + } + }; + + tracing::info!( + "Created session {} for client {} with nonce {}", + session.id, + client_id, + nonce + ); + + Ok((StatusCode::OK, Json(SetupResponse { nonce }))) +} + +// GET /authorize/{nonce}?response_type=code&client_id={client_id}&redirect_uri={uri}&state={state} +pub async fn authorize( + State(state): State<AppState>, + Path(nonce): Path<String>, + Query(params): Query<AuthorizeQuery>, + headers: axum::http::HeaderMap, +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { + tracing::info!( + "Authorize request for client: {}, nonce: {}, state: {}, redirect_uri: {}, scope: {}", + params.client_id, + nonce, + params.state, + params.redirect_uri, + params.scope + ); + + if params.response_type != "code" { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("invalid_request")), + )); + } + + let session_data = crate::db::sessions::get_session_for_authorize( + &state.pool, + &nonce, + &params.client_id, + &params.scope, + &params.redirect_uri, + &params.state, + ) + .await + .map_err(|e| { + tracing::error!("DB error in authorize: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + let data = match session_data { + Some(d) => d, + None => { + tracing::warn!("Session not found for nonce: {}", nonce); + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse::new("session_not_found")), + )); + } + }; + + // Validate redirect_uri against client's registered URIs + let redirect_uri_valid = data + .allowed_redirect_uris + .as_ref() + .map(|uris| { + uris.split(',') + .map(|s| s.trim()) + .any(|uri| uri == params.redirect_uri) + }) + .unwrap_or(false); + + if !redirect_uri_valid { + tracing::warn!( + "Invalid redirect_uri for client {}: {}", + params.client_id, + params.redirect_uri + ); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("invalid_redirect_uri")), + )); + } + + // Backend validation + if data.expires_at < Utc::now() { + tracing::warn!("Session expired: {}", data.session_id); + return Err(( + StatusCode::GONE, + Json(ErrorResponse::new("session_expired")), + )); + } + + // Check status for idempotency + match data.status { + SessionStatus::Authorized => { + tracing::info!( + "Session {} already authorized, returning cached response", + data.session_id + ); + + let verification_id = data + .request_id + .and_then(|id| uuid::Uuid::parse_str(&id).ok()) + .unwrap_or(uuid::Uuid::nil()); + + let accept_html = headers + .get(header::ACCEPT) + .and_then(|h| h.to_str().ok()) + .map_or(false, |v| v.contains("text/html")); + + if accept_html { + use askama::Template; + + #[derive(Template)] + #[template(path = "authorize.html")] + struct AuthorizeTemplate { + verification_id: String, + verification_url: String, + verification_deeplink: String, + state: String, + redirect_uri: String, + } + + let template = AuthorizeTemplate { + verification_id: verification_id.to_string(), + verification_url: data.verification_url.clone().unwrap_or_default(), + verification_deeplink: data.verification_deeplink.clone().unwrap_or_default(), + state: params.state.clone(), + redirect_uri: params.redirect_uri.clone(), + }; + + let html = template.render().map_err(|e| { + tracing::error!("Template render error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + return Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + html, + ).into_response()); + } + + return Ok(PrettyJson(AuthorizeResponse { + verification_id, + verification_url: data.verification_url.clone().unwrap_or_default(), + verification_deeplink: data.verification_deeplink, + state: params.state.clone() + }).into_response()); + } + + SessionStatus::Pending => { + // Proceed with authorization + } + + _ => { + tracing::warn!( + "Session {} in invalid status: {:?}", + data.session_id, + data.status + ); + return Err(( + StatusCode::CONFLICT, + Json(ErrorResponse::new("invalid_session_status")), + )); + } + } + + // Build presentation definition from scope + let presentation_definition = build_presentation_definition(&data.scope); + + // Call Swiyu Verifier + let verifier_url = format!("{}{}", data.verifier_url, data.verifier_management_api_path); + + let verifier_request = SwiyuCreateVerificationRequest { + accepted_issuer_dids: default_accepted_issuer_dids(), + trust_anchors: None, + jwt_secured_authorization_request: Some(true), + response_mode: ResponseMode::DirectPost, + response_type: "vp_token".to_string(), + presentation_definition, + configuration_override: ConfigurationOverride::default(), + dcql_query: None, + }; + + tracing::debug!( + "Swiyu verifier request: {}", + serde_json::to_string_pretty(&verifier_request).unwrap() + ); + tracing::debug!("Calling Swiyu verifier at: {}", verifier_url); + + let verifier_response = state + .http_client + .post(&verifier_url) + .json(&verifier_request) + .send() + .await + .map_err(|e| { + tracing::error!("Failed to call Swiyu verifier: {}", e); + ( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse::new("verifier_unavailable")), + ) + })?; + + if !verifier_response.status().is_success() { + let status = verifier_response.status(); + let body = verifier_response.text().await.unwrap_or_default(); + tracing::error!("Swiyu verifier returned error {}: {}", status, body); + return Err(( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse::new("verifier_error")), + )); + } + + let swiyu_response: SwiyuManagementResponse = verifier_response.json().await.map_err(|e| { + tracing::error!("Failed to parse Swiyu response: {}", e); + ( + StatusCode::BAD_GATEWAY, + Json(ErrorResponse::new("verifier_invalid_response")), + ) + })?; + + // Update session with verifier data + let result = crate::db::sessions::update_session_authorized( + &state.pool, + data.session_id, + &swiyu_response.verification_url, + swiyu_response.verification_deeplink.as_deref(), + &swiyu_response.id.to_string(), + swiyu_response.request_nonce.as_deref(), + ) + .await + .map_err(|e| { + tracing::error!("Failed to update session: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + tracing::info!( + "Session {} authorized, verification_id: {}", + data.session_id, + swiyu_response.id + ); + + let accept_html = headers + .get(header::ACCEPT) + .and_then(|h| h.to_str().ok()) + .map_or(false, |v| v.contains("text/html")); + + if accept_html { + use askama::Template; + + #[derive(Template)] + #[template(path = "authorize.html")] + struct AuthorizeTemplate { + verification_id: String, + verification_url: String, + verification_deeplink: String, + state: String, + redirect_uri: String, + } + + let template = AuthorizeTemplate { + verification_id: swiyu_response.id.to_string(), + verification_url: result.verification_url.clone(), + verification_deeplink: swiyu_response.verification_deeplink.clone().unwrap_or_default(), + state: params.state.clone(), + redirect_uri: params.redirect_uri.clone(), + }; + + let html = template.render().map_err(|e| { + tracing::error!("Template render error: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + return Ok(( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + html, + ).into_response()); + } + + Ok(PrettyJson(AuthorizeResponse { + verification_id: swiyu_response.id, + verification_url: result.verification_url, + verification_deeplink: swiyu_response.verification_deeplink, + state: params.state.clone() + }).into_response()) +} + +/// Build a presentation definition from a space-delimited scope string +/// +/// Example: "age_over_18" or "first_name last_name" +fn build_presentation_definition(scope: &str) -> PresentationDefinition { + use std::collections::HashMap; + use uuid::Uuid; + + let attributes: Vec<&str> = scope.split_whitespace().collect(); + + tracing::debug!( + "Building presentation definition for attributes: {:?}", + attributes + ); + + // First field: $.vct with filter for credential type + let vct_field = Field { + path: vec!["$.vct".to_string()], + id: None, + name: None, + purpose: None, + filter: Some(Filter { + filter_type: "string".to_string(), + const_value: Some("betaid-sdjwt".to_string()), + }), + }; + + // Attribute fields from scope + let mut fields: Vec<Field> = vec![vct_field]; + for attr in &attributes { + fields.push(Field { + path: vec![format!("$.{}", attr)], + id: None, + name: None, + purpose: None, + filter: None, + }); + } + + let mut format = HashMap::new(); + format.insert( + "vc+sd-jwt".to_string(), + FormatAlgorithm { + sd_jwt_alg_values: vec!["ES256".to_string()], + kb_jwt_alg_values: vec!["ES256".to_string()], + }, + ); + + let input_descriptor = InputDescriptor { + id: Uuid::new_v4().to_string(), + name: None, + purpose: None, + format: Some(format), + constraints: Constraint { fields }, + }; + + PresentationDefinition { + id: Uuid::new_v4().to_string(), + name: Some("Over 18 Verification".to_string()), + purpose: Some("Verify age is over 18".to_string()), + format: None, // No format at top level + input_descriptors: vec![input_descriptor], + } +} + +// POST /token +pub async fn token( + State(state): State<AppState>, + Form(request): Form<TokenRequest>, +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { + tracing::info!("Token request for code: {}", request.code); + + // Validate grant_type + if request.grant_type != "authorization_code" { + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("unsupported_grant_type")), + )); + } + + // Authenticate client + let client = crate::db::clients::authenticate_client( + &state.pool, + &request.client_id, + &request.client_secret, + ) + .await + .map_err(|e| { + tracing::error!("DB error during client authentication: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + let client = match client { + Some(c) => c, + None => { + tracing::warn!("Client authentication failed for {}", request.client_id); + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new("invalid_client")), + )); + } + }; + + // Fetch code (idempotent) + let code_data = + crate::db::authorization_codes::get_code_for_token_exchange(&state.pool, &request.code) + .await + .map_err(|e| { + tracing::error!("DB error in token exchange: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + let data = match code_data { + Some(d) => d, + None => { + tracing::warn!("Authorization code not found or expired: {}", request.code); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("invalid_grant")), + )); + } + }; + + // Verify the authorization code belongs to the client + if data.client_id != client.id { + tracing::warn!( + "Authorization code {} does not belong to the client {}", + request.code, + request.client_id + ); + + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("invalid_grant")), + )); + } + + // Check for existing token + if let Some(existing_token) = data.existing_token { + tracing::info!( + "Token already exists for session {}, returning cached response", + data.session_id + ); + return Ok(( + StatusCode::OK, + Json(TokenResponse { + access_token: existing_token, + token_type: "Bearer".to_string(), + expires_in: 3600, + }), + )); + } + + // Check if code was already used + if data.was_already_used { + tracing::warn!("Authorization code {} was already used", request.code); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("invalid_grant")), + )); + } + + // Validate session status + if data.session_status != SessionStatus::Verified { + tracing::warn!( + "Session {} not in verified status: {:?}", + data.session_id, + data.session_status + ); + return Err(( + StatusCode::BAD_REQUEST, + Json(ErrorResponse::new("invalid_grant")), + )); + } + + // Generate new token and complete session + let access_token = crypto::generate_token(state.config.crypto.token_bytes); + let token = crate::db::tokens::create_token_and_complete_session( + &state.pool, + data.session_id, + &access_token, + 3600, // 1 hour + ) + .await + .map_err(|e| { + tracing::error!("Failed to create token: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + tracing::info!("Token created for session {}", data.session_id); + + Ok(( + StatusCode::OK, + Json(TokenResponse { + access_token: token.token, + token_type: "Bearer".to_string(), + expires_in: 3600, + }), + )) +} + +// GET /info +pub async fn info( + State(state): State<AppState>, + headers: axum::http::HeaderMap, +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { + tracing::info!("Info request received"); + + // Extract token from Authorization header + let auth_header = headers + .get(header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()); + + let token = match auth_header { + Some(h) if h.starts_with("Bearer ") => &h[7..], + _ => { + tracing::warn!("Missing or malformed Authorization header"); + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new("invalid_token")), + )); + } + }; + + // Fetch token with session data (idempotent) + let token_data = crate::db::tokens::get_token_with_session(&state.pool, token) + .await + .map_err(|e| { + tracing::error!("DB error in info: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + let data = match token_data { + Some(d) => d, + None => { + tracing::warn!("Token not found or expired"); + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new("invalid_token")), + )); + } + }; + + // Validate token + if data.revoked { + tracing::warn!("Token {} is revoked", data.token_id); + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new("invalid_token")), + )); + } + + if data.session_status != SessionStatus::Completed { + tracing::warn!("Session not completed: {:?}", data.session_status); + return Err(( + StatusCode::UNAUTHORIZED, + Json(ErrorResponse::new("invalid_token")), + )); + } + + // Return verifiable credential + let credential = VerifiableCredential { + data: data.verifiable_credential.unwrap_or(json!({})), + }; + + tracing::info!("Returning credential for token {}", data.token_id); + + Ok((StatusCode::OK, Json(credential))) +} + +// POST /notification +// Always returns 200 OK to Swiyu - errors are logged internally +pub async fn notification_webhook( + State(state): State<AppState>, + Json(webhook): Json<NotificationRequest>, +) -> impl IntoResponse { + tracing::info!( + "Webhook received from Swiyu: verification_id={}, timestamp={}", + webhook.verification_id, + webhook.timestamp + ); + + // Lookup session by request_id (verification_id) + let session_data = match crate::db::sessions::get_session_for_notification( + &state.pool, + &webhook.verification_id.to_string(), + ) + .await + { + Ok(Some(data)) => data, + Ok(None) => { + tracing::warn!( + "Session not found for verification_id: {}", + webhook.verification_id + ); + return StatusCode::OK; + } + Err(e) => { + tracing::error!("DB error looking up session: {}", e); + return StatusCode::OK; + } + }; + + // Validate session status + if session_data.status != SessionStatus::Authorized { + tracing::warn!( + "Session {} not in authorized status: {:?}", + session_data.session_id, + session_data.status + ); + return StatusCode::OK; + } + + // Call Swiyu verifier to get verification result + let verifier_url = format!( + "{}{}/{}", + session_data.verifier_url, + session_data.verifier_management_api_path, + webhook.verification_id + ); + + tracing::debug!("Fetching verification result from: {}", verifier_url); + + let verifier_response = match state.http_client.get(&verifier_url).send().await { + Ok(resp) => resp, + Err(e) => { + tracing::error!("Failed to call Swiyu verifier: {}", e); + return StatusCode::OK; + } + }; + + if !verifier_response.status().is_success() { + let status = verifier_response.status(); + tracing::error!("Swiyu verifier returned error: {}", status); + return StatusCode::OK; + } + + let swiyu_result: SwiyuManagementResponse = match verifier_response.json().await { + Ok(r) => r, + Err(e) => { + tracing::error!("Failed to parse Swiyu response: {}", e); + return StatusCode::OK; + } + }; + + // Determine status based on verification result + let (new_status, status_str) = match swiyu_result.state { + SwiyuVerificationStatus::Success => (SessionStatus::Verified, "verified"), + SwiyuVerificationStatus::Failed => (SessionStatus::Failed, "failed"), + SwiyuVerificationStatus::Pending => { + tracing::info!( + "Verification {} still pending, ignoring webhook", + webhook.verification_id + ); + return StatusCode::OK; + } + }; + + // Generate authorization code + let authorization_code = crypto::generate_authorization_code(state.config.crypto.authorization_code_bytes); + + // Construct GET request URL: redirect_uri?code=XXX&state=YYY + let redirect_uri = session_data.redirect_uri.as_ref() + .unwrap_or(&session_data.webhook_url); + let oauth_state = session_data.state.as_deref().unwrap_or(""); + + let webhook_url = format!( + "{}?code={}&state={}", + redirect_uri, + authorization_code, + oauth_state + ); + + // Update session, create auth code, and queue webhook (GET request, empty body) + match crate::db::sessions::verify_session_and_queue_notification( + &state.pool, + session_data.session_id, + new_status, + &authorization_code, + 10, // 10 minutes for auth code expiry + session_data.client_id, + &webhook_url, + "", // Empty body for GET request + swiyu_result.wallet_response.as_ref(), + ) + .await + { + Ok(code) => { + tracing::info!( + "Session {} updated to {}, auth code created, webhook queued", + session_data.session_id, + status_str + ); + tracing::debug!("Generated authorization code: {}", code); + } + Err(e) => { + tracing::error!("Failed to update session and queue notification: {}", e); + } + } + + StatusCode::OK +} + +pub async fn status( + State(state): State<AppState>, + Path(verification_id): Path<String>, + Query(params): Query<crate::models::StatusQuery>, +) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { + tracing::info!( + "Status check for verification_id: {}, state: {}", + verification_id, + params.state + ); + + let session_data = crate::db::sessions::get_session_for_status( + &state.pool, + &verification_id, + ) + .await + .map_err(|e| { + tracing::error!("DB error in status: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse::new("internal_error")), + ) + })?; + + let data = match session_data { + Some(d) => d, + None => { + tracing::warn!("Session not found for verification_id: {}", verification_id); + return Err(( + StatusCode::NOT_FOUND, + Json(ErrorResponse::new("session_not_found")), + )); + } + }; + + if data.state.as_deref() != Some(&params.state) { + tracing::warn!( + "State mismatch for verification_id: {} (expected: {:?}, got: {})", + verification_id, + data.state, + params.state + ); + return Err(( + StatusCode::FORBIDDEN, + Json(ErrorResponse::new("invalid_state")), + )); + } + + let status_str = match data.status { + crate::db::sessions::SessionStatus::Pending => "pending", + crate::db::sessions::SessionStatus::Authorized => "authorized", + crate::db::sessions::SessionStatus::Verified => "verified", + crate::db::sessions::SessionStatus::Failed => "failed", + crate::db::sessions::SessionStatus::Expired => "expired", + crate::db::sessions::SessionStatus::Completed => "completed", + }; + + let response = crate::models::StatusResponse { + status: status_str.to_string(), + code: data.authorization_code, + redirect_uri: data.redirect_uri, + }; + + tracing::info!( + "Status check for verification_id {} returned: {}", + verification_id, + status_str + ); + + Ok((StatusCode::OK, Json(response))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_presentation_definition_single_attribute() { + let scope = "age_over_18"; + let pd = build_presentation_definition(scope); + + // Verify structure + assert!(!pd.id.is_empty()); + assert_eq!(pd.name, Some("Over 18 Verification".to_string())); + assert_eq!(pd.input_descriptors.len(), 1); + + // Verify fields: vct filter + requested attribute + let fields = &pd.input_descriptors[0].constraints.fields; + assert_eq!(fields.len(), 2); + + // First field is vct with filter + assert_eq!(fields[0].path, vec!["$.vct"]); + assert!(fields[0].filter.is_some()); + let filter = fields[0].filter.as_ref().unwrap(); + assert_eq!(filter.filter_type, "string"); + assert_eq!(filter.const_value, Some("betaid-sdjwt".to_string())); + + // Second field is the requested attribute + assert_eq!(fields[1].path, vec!["$.age_over_18"]); + assert!(fields[1].filter.is_none()); + } + + #[test] + fn test_build_presentation_definition_multiple_attributes() { + let scope = "first_name last_name date_of_birth"; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + // vct + 3 attributes = 4 fields + assert_eq!(fields.len(), 4); + + assert_eq!(fields[0].path, vec!["$.vct"]); // vct first + assert_eq!(fields[1].path, vec!["$.first_name"]); + assert_eq!(fields[2].path, vec!["$.last_name"]); + assert_eq!(fields[3].path, vec!["$.date_of_birth"]); + } + + #[test] + fn test_build_presentation_definition_extra_whitespace() { + let scope = "first_name last_name"; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + // split_whitespace handles multiple spaces correctly + // vct + 2 attributes = 3 fields + assert_eq!(fields.len(), 3); + assert_eq!(fields[0].path, vec!["$.vct"]); + assert_eq!(fields[1].path, vec!["$.first_name"]); + assert_eq!(fields[2].path, vec!["$.last_name"]); + } + + #[test] + fn test_build_presentation_definition_empty_scope() { + let scope = ""; + let pd = build_presentation_definition(scope); + + let fields = &pd.input_descriptors[0].constraints.fields; + // Only vct field when scope is empty + assert_eq!(fields.len(), 1); + assert_eq!(fields[0].path, vec!["$.vct"]); + } + + #[test] + fn test_build_presentation_definition_no_top_level_format() { + let scope = "age_over_18"; + let pd = build_presentation_definition(scope); + + // No format at top level + assert!(pd.format.is_none()); + } + + #[test] + fn test_build_presentation_definition_input_descriptor_structure() { + let scope = "age_over_18"; + let pd = build_presentation_definition(scope); + + let descriptor = &pd.input_descriptors[0]; + + // Verify descriptor has valid UUID + assert!(!descriptor.id.is_empty()); + + // Verify no name/purpose at descriptor level + assert!(descriptor.name.is_none()); + assert!(descriptor.purpose.is_none()); + + // Verify format is specified at descriptor level + assert!(descriptor.format.is_some()); + let format = descriptor.format.as_ref().unwrap(); + assert!(format.contains_key("vc+sd-jwt")); + let alg = &format["vc+sd-jwt"]; + assert_eq!(alg.sd_jwt_alg_values, vec!["ES256"]); + assert_eq!(alg.kb_jwt_alg_values, vec!["ES256"]); + } +} diff --git a/oauth2_gateway/src/lib.rs b/kych_oauth2_gateway/src/lib.rs diff --git a/kych_oauth2_gateway/src/main.rs b/kych_oauth2_gateway/src/main.rs @@ -0,0 +1,94 @@ +use anyhow::Result; +use axum::{ + Router, + routing::{get, post}, +}; +use clap::Parser; +use kych_oauth2_gateway_lib::{config::Config, db, handlers, state::AppState}; +use std::{fs, os::unix::fs::PermissionsExt}; +use tower_http::{services::ServeDir, trace::TraceLayer}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Parser, Debug)] +#[command(version)] +struct Args { + #[arg(short = 'c', long = "config", value_name = "FILE")] + config: String, + + #[arg(short = 'L', long = "log-level", value_name = "LEVEL", default_value = "INFO")] + log_level: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + let level = args.log_level.to_lowercase(); + let filter = format!( + "kych_oauth2_gateway={},kych_oauth2_gateway_lib={},tower_http={},sqlx=warn", + level, level, level + ); + + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| filter.into()), + ) + .with( + tracing_subscriber::fmt::layer() + .compact() + .with_ansi(false) + .with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()), + ) + .init(); + + tracing::info!("Starting Kych OAuth2 Gateway v{}", env!("CARGO_PKG_VERSION")); + tracing::info!("Loading configuration from: {}", args.config); + + let config = Config::from_file(&args.config)?; + + tracing::info!("Connecting to database: {}", config.database.url); + let pool = db::create_pool(&config.database.url).await?; + + let state = AppState::new(config.clone(), pool); + + let app = Router::new() + .route("/health", get(handlers::health_check)) + .route("/setup/{client_id}", post(handlers::setup)) + .route("/authorize/{nonce}", get(handlers::authorize)) + .route("/token", post(handlers::token)) + .route("/info", get(handlers::info)) + .route("/notification", post(handlers::notification_webhook)) + .route("/status/{verification_id}", get(handlers::status)) + .nest_service("/js", ServeDir::new("js")) + .layer(TraceLayer::new_for_http()) + .with_state(state); + + if config.server.is_unix_socket() { + let socket_path = config.server.socket_path.as_ref().unwrap(); + + if std::path::Path::new(socket_path).exists() { + tracing::warn!("Removing existing socket file: {}", socket_path); + std::fs::remove_file(socket_path)?; + } + + let listener = tokio::net::UnixListener::bind(socket_path)?; + let permissions = std::fs::Permissions::from_mode(0o766); + let _ = fs::set_permissions(socket_path, permissions); + + tracing::info!("Server listening on Unix socket: {}", socket_path); + + axum::serve(listener, app).await?; + } else { + let host = config.server.host.as_ref().unwrap(); + let port = config.server.port.unwrap(); + let addr = format!("{}:{}", host, port); + + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!("Server listening on {}", addr); + + axum::serve(listener, app).await?; + } + + Ok(()) +} diff --git a/kych_oauth2_gateway/src/models.rs b/kych_oauth2_gateway/src/models.rs @@ -0,0 +1,291 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use std::collections::HashMap; +use axum::{ + response::{IntoResponse, Response}, + http::{StatusCode, header}, +}; + +pub struct PrettyJson<T>(pub T); + +impl<T> IntoResponse for PrettyJson<T> +where + T: Serialize, +{ + fn into_response(self) -> Response { + match serde_json::to_string_pretty(&self.0) { + Ok(json) => ( + StatusCode::OK, + [(header::CONTENT_TYPE, "application/json")], + json, + ).into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Serialization error: {}", e), + ).into_response(), + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct SetupResponse { + pub nonce: String, +} + +#[derive(Debug, Deserialize)] +pub struct AuthorizeQuery { + pub response_type: String, + pub client_id: String, + pub redirect_uri: String, + pub state: String, + pub scope: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct AuthorizeResponse { + #[serde(rename = "verificationId")] + pub verification_id: Uuid, + pub verification_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_deeplink: Option<String>, + pub state: String, +} + +// Token endpoint +// WARING: RFC 6749 also requires: +// - redirect_uri +#[derive(Debug, Deserialize, Serialize)] +pub struct TokenRequest { + pub grant_type: String, + pub code: String, + pub client_id: String, + pub client_secret: String, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: u64, +} + +// Info endpoint +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct VerifiableCredential { + #[serde(flatten)] + pub data: serde_json::Value, +} + +// Notification webhook from Swiyu Verifier +#[derive(Debug, Deserialize, Serialize)] +pub struct NotificationRequest { + pub verification_id: Uuid, + pub timestamp: String, +} + +// Status endpoint +#[derive(Debug, Deserialize)] +pub struct StatusQuery { + pub state: String, +} + +#[derive(Debug, Serialize)] +pub struct StatusResponse { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub redirect_uri: Option<String>, +} + +// Notification payload sent to Client (Exchange, etc.) +#[derive(Debug, Serialize)] +pub struct ClientNotification { + pub nonce: String, + pub status: String, + pub code: String, + pub verification_id: Uuid, + pub timestamp: String, +} + +// Error response +#[derive(Debug, Serialize, Deserialize)] +pub struct ErrorResponse { + pub error: String, +} + +impl ErrorResponse { + pub fn new(error: impl Into<String>) -> Self { + Self { + error: error.into(), + } + } +} + +// Swiyu Verifier API models + +/// Default issuer DID for Swiyu verification +pub fn default_accepted_issuer_dids() -> Vec<String> { + vec!["did:tdw:QmPEZPhDFR4nEYSFK5bMnvECqdpf1tPTPJuWs9QrMjCumw:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:9a5559f0-b81c-4368-a170-e7b4ae424527".to_string()] +} + +/// Request body for creating a verification with Swiyu Verifier +/// POST /management/api/verifications +#[derive(Debug, Serialize, Deserialize)] +pub struct SwiyuCreateVerificationRequest { + #[serde(default = "default_accepted_issuer_dids")] + pub accepted_issuer_dids: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub trust_anchors: Option<Vec<TrustAnchor>>, + + /// If omitted, Swiyu defaults to true (beta requires true) + #[serde(skip_serializing_if = "Option::is_none")] + pub jwt_secured_authorization_request: Option<bool>, + + /// Response mode: how the wallet sends the response back + /// REQUIRED - will throw NullPointerException if omitted + pub response_mode: ResponseMode, + + /// Response type - must be "vp_token" for VP requests + pub response_type: String, + + pub presentation_definition: PresentationDefinition, + + pub configuration_override: ConfigurationOverride, + + /// Optional - (wallet migration in progress) + #[serde(skip_serializing_if = "Option::is_none")] + pub dcql_query: Option<serde_json::Value>, +} + +/// Trust anchor for credential verification +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TrustAnchor { + pub did: String, + pub trust_registry_uri: String, +} + +/// Response mode type +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ResponseMode { + /// Wallet sends a clear text response + DirectPost, + + /// Wallet sends an encrypted response + #[serde(rename = "direct_post.jwt")] + DirectPostJwt, +} + +/// Configuration override for a specific verification +/// Can be empty object with all fields set to None +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct ConfigurationOverride { + /// Override for the EXTERNAL_URL - the url the wallet should call + #[serde(skip_serializing_if = "Option::is_none")] + pub external_url: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub verifier_did: Option<String>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_method: Option<String>, + + /// ID of the key in the HSM + #[serde(skip_serializing_if = "Option::is_none")] + pub key_id: Option<String>, + + /// The pin which protects the key in the HSM + #[serde(skip_serializing_if = "Option::is_none")] + pub key_pin: Option<String>, +} + +/// Presentation Definition according to DIF Presentation Exchange +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PresentationDefinition { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option<HashMap<String, FormatAlgorithm>>, + pub input_descriptors: Vec<InputDescriptor>, +} + +/// Input descriptor describing required credential attributes +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct InputDescriptor { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option<HashMap<String, FormatAlgorithm>>, + pub constraints: Constraint, +} + +/// Constraints on credential fields +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Constraint { + pub fields: Vec<Field>, +} + +/// Field specification with JSONPath +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Field { + pub path: Vec<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub purpose: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub filter: Option<Filter>, +} + +/// Filter for field constraints +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Filter { + #[serde(rename = "type")] + pub filter_type: String, + #[serde(rename = "const", skip_serializing_if = "Option::is_none")] + pub const_value: Option<String>, +} + +/// Format algorithm specification for SD-JWT +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FormatAlgorithm { + #[serde(rename = "sd-jwt_alg_values")] + pub sd_jwt_alg_values: Vec<String>, + #[serde(rename = "kb-jwt_alg_values")] + pub kb_jwt_alg_values: Vec<String>, +} + +/// Response from Swiyu Verifier Management API +/// Used for both POST /management/api/verifications and GET /management/api/verifications/{id} +#[derive(Debug, Serialize, Deserialize)] +pub struct SwiyuManagementResponse { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_nonce: Option<String>, + pub state: SwiyuVerificationStatus, + pub verification_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub verification_deeplink: Option<String>, + pub presentation_definition: PresentationDefinition, + #[serde(skip_serializing_if = "Option::is_none")] + pub dcql_query: Option<serde_json::Value>, + #[serde(skip_serializing_if = "Option::is_none")] + pub wallet_response: Option<serde_json::Value>, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "UPPERCASE")] +pub enum SwiyuVerificationStatus { + Pending, + Success, + Failed, +} diff --git a/oauth2_gateway/src/state.rs b/kych_oauth2_gateway/src/state.rs diff --git a/oauth2_gateway/src/worker.rs b/kych_oauth2_gateway/src/worker.rs diff --git a/kych_oauth2_gateway/templates/authorize.html b/kych_oauth2_gateway/templates/authorize.html @@ -0,0 +1,194 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Identity Verification</title> + <script src="/js/qrcode.min.js"></script> + <style> + body { + font-family: system-ui, -apple-system, sans-serif; + max-width: 600px; + margin: 50px auto; + padding: 20px; + text-align: center; + background: #f5f5f5; + } + .container { + background: white; + padding: 40px; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + h1 { + color: #333; + margin-bottom: 10px; + } + p { + color: #666; + margin-bottom: 30px; + } + #qrcode { + margin: 20px auto; + display: inline-block; + padding: 20px; + background: white; + border: 2px solid #e0e0e0; + border-radius: 8px; + } + #qr-fallback { + display: none; + margin: 20px 0; + word-break: break-all; + } + .button { + display: inline-block; + padding: 12px 32px; + background: #007bff; + color: white; + text-decoration: none; + border-radius: 4px; + margin: 20px 0; + font-weight: 500; + transition: background 0.3s; + } + .button:hover { + background: #0056b3; + } + .error { + color: #dc3545; + padding: 16px; + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 4px; + margin: 20px 0; + display: none; + } + .status { + color: #6c757d; + font-size: 14px; + margin: 20px 0; + padding: 12px; + background: #e7f3ff; + border-radius: 4px; + } + .spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid #f3f3f3; + border-top: 2px solid #007bff; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 8px; + vertical-align: middle; + } + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + </style> +</head> +<body> + <div class="container"> + <h1>Identity Verification</h1> + <p>Scan the QR code with your Swiyu wallet or click the button below:</p> + + <div id="qrcode"></div> + <div id="qr-fallback"> + <p>QR code failed to load. Use this link:</p> + <a href="{{ verification_url }}">{{ verification_url }}</a> + </div> + + {% if !verification_deeplink.is_empty() %} + <div> + <a href="{{ verification_deeplink }}" class="button">Open Swiyu Wallet</a> + </div> + {% endif %} + + <div id="status" class="status"> + <span class="spinner"></span> + <span>Waiting for verification...</span> + </div> + <div id="error" class="error"></div> + </div> + + <script> + const verificationId = "{{ verification_id }}"; + const state = "{{ state }}"; + const redirectUri = "{{ redirect_uri }}"; + + try { + new QRCode(document.getElementById("qrcode"), { + text: "{{ verification_url }}", + width: 256, + height: 256, + correctLevel: QRCode.CorrectLevel.M + }); + } catch (error) { + console.error('QR code generation failed:', error); + document.getElementById('qrcode').style.display = 'none'; + document.getElementById('qr-fallback').style.display = 'block'; + } + + let attempts = 0; + const maxAttempts = 100; + const pollInterval = 3000; + + async function checkStatus() { + attempts++; + + if (attempts > maxAttempts) { + document.getElementById('status').style.display = 'none'; + document.getElementById('error').textContent = 'Verification timeout. Please try again.'; + document.getElementById('error').style.display = 'block'; + return; + } + + try { + const response = await fetch(`/status/${verificationId}?state=${encodeURIComponent(state)}`); + + if (!response.ok) { + if (response.status === 403) { + document.getElementById('status').style.display = 'none'; + document.getElementById('error').textContent = 'Security validation failed. Please start again.'; + document.getElementById('error').style.display = 'block'; + return; + } + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + + if (data.status === 'verified' && data.code) { + const separator = redirectUri.includes('?') ? '&' : '?'; + window.location.href = `${redirectUri}${separator}code=${data.code}&state=${encodeURIComponent(state)}`; + return; + } + + if (data.status === 'failed') { + document.getElementById('status').style.display = 'none'; + document.getElementById('error').textContent = 'Verification failed. Please try again.'; + document.getElementById('error').style.display = 'block'; + return; + } + + if (data.status === 'expired') { + document.getElementById('status').style.display = 'none'; + document.getElementById('error').textContent = 'Session expired. Please start again.'; + document.getElementById('error').style.display = 'block'; + return; + } + + setTimeout(checkStatus, pollInterval); + + } catch (error) { + console.error('Polling error:', error); + setTimeout(checkStatus, pollInterval); + } + } + + setTimeout(checkStatus, pollInterval); + </script> +</body> +</html> diff --git a/oauth2_gateway/tests/client_cli.rs b/kych_oauth2_gateway/tests/client_cli.rs diff --git a/oauth2_gateway/tests/db.rs b/kych_oauth2_gateway/tests/db.rs diff --git a/oauth2_gateway/Cargo.toml b/oauth2_gateway/Cargo.toml @@ -1,66 +0,0 @@ -[package] -name = "oauth2-gateway" -version = "0.0.1" -edition = "2024" - -[lib] -name = "oauth2_gateway" -path = "src/lib.rs" - -[[bin]] -name = "oauth2-gateway" -path = "src/main.rs" - -[[bin]] -name = "webhook-worker" -path = "src/bin/webhook_worker.rs" - -[[bin]] -name = "client-mgmt" -path = "src/bin/client_management_cli.rs" - -[dependencies] -# Web framework -axum = "0.8.6" -axum-test = "18.1.0" -tokio = { version = "1.48.0", features = ["full"] } -tower = "0.5" -tower-http = { version = "0.6.6", features = ["trace"] } - -# Serialization -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.145" - -# HTTP client -reqwest = { version = "0.12", features = ["json"] } - -# Configuration -rust-ini = "0.21.3" -clap = { version = "4.5.49", features = ["derive"] } - -# Utilities -uuid = { version = "1.18.1", features = ["v4", "serde"] } -chrono = { version = "0.4.42", features = ["serde"] } - -# Logging -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.20", features = ["env-filter", "local-time"] } - -# Error handling -anyhow = "1.0.100" - -# Environment -dotenvy = "0.15" - -# Cryptography -rand = "0.8.5" -bcrypt = "0.15" -base64 = "0.22.1" - -# Database -sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] } - -[dev-dependencies] -tempfile = "3.8" -wiremock = "0.6" -serial_test = "3.2.0" diff --git a/oauth2_gateway/src/bin/client_management_cli.rs b/oauth2_gateway/src/bin/client_management_cli.rs @@ -1,420 +0,0 @@ -//! OAuth2 Gateway CLI -//! -//! Command-line tool for managing OAuth2 Gateway clients. -//! -//! Set DATABASE_URL environment variable to connect to the database. -//! Usage: -//! client-mgmt client list -//! client-mgmt client show <client_id> -//! client-mgmt client create --client-id <id> --secret <secret> ... -//! client-mgmt client update <client_id> --webhook-url <url> -//! client-mgmt client delete <client_id> - -use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; -use ini::Ini; -use oauth2_gateway::db; -use std::env; -use std::collections::HashSet; - -#[derive(Parser, Debug)] -#[command(name = "client-mgmt")] - -#[command(version)] -#[command(about = "OAuth2 Gateway client management CLI")] -#[command(after_help = "Environment variables:\n DATABASE_URL PostgreSQL connection string (required)")] -struct Args { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand, Debug)] -enum Commands { - List, - - Show { - client_id: String, - }, - - Create { - /// Unique client identifier - #[arg(long)] - client_id: String, - - /// Client secret (stored as hash) - #[arg(long)] - secret: String, - - /// Webhook URL for notifications - #[arg(long)] - webhook_url: String, - - /// Swiyu verifier base URL - #[arg(long)] - verifier_url: String, - - /// Verifier management API path (default: /management/api/verifications) - #[arg(long)] - verifier_api_path: Option<String>, - - /// Default redirect URI for OAuth2 flow - #[arg(long)] - redirect_uri: Option<String>, - - /// Comma-separated list of accepted issuer DIDs - #[arg(long)] - accepted_issuer_dids: Option<String>, - }, - - Update { - client_id: String, - - #[arg(long)] - webhook_url: Option<String>, - - #[arg(long)] - verifier_url: Option<String>, - - #[arg(long)] - verifier_api_path: Option<String>, - - #[arg(long)] - redirect_uri: Option<String>, - - #[arg(long)] - accepted_issuer_dids: Option<String>, - }, - - /// Sync clients from a configuration file - Sync { - /// Path to clients.conf file - config_file: String, - - /// Remove clients not in config file - #[arg(long)] - prune: bool, - }, - - /// Delete a client (WARNING: cascades to all sessions) - Delete { - client_id: String, - - #[arg(long, short = 'y')] - yes: bool, - }, -} - -#[tokio::main] -async fn main() -> Result<()> { - // Load .env (ignore if missing) - let _ = dotenvy::dotenv(); - - let args = Args::parse(); - - let database_url = env::var("DATABASE_URL") - .context("DATABASE_URL environment variable not set")?; - - let pool = db::create_pool(&database_url) - .await - .context("Failed to connect to database")?; - - match args.command { - Commands::List => cmd_list_clients(&pool).await?, - Commands::Show { client_id } => cmd_show_client(&pool,&client_id).await?, - Commands::Create { - client_id, - secret, - webhook_url, - verifier_url, - verifier_api_path, - redirect_uri, - accepted_issuer_dids, - } => { - cmd_create_client( - &pool, - &client_id, - &secret, - &webhook_url, - &verifier_url, - verifier_api_path.as_deref(), - redirect_uri.as_deref(), - accepted_issuer_dids.as_deref(), - ) - .await? - } - Commands::Update { - client_id, - webhook_url, - verifier_url, - verifier_api_path, - redirect_uri, - accepted_issuer_dids, - } => { - cmd_update_client( - &pool, - &client_id, - webhook_url.as_deref(), - verifier_url.as_deref(), - verifier_api_path.as_deref(), - redirect_uri.as_deref(), - accepted_issuer_dids.as_deref(), - ) - .await? - } - Commands::Sync { config_file, prune } => { - cmd_sync_clients(&pool, &config_file, prune).await? - } - Commands::Delete { client_id, yes } => { - cmd_delete_client(&pool, &client_id, yes).await? - } - } - - Ok(()) -} - -fn print_client_details(client: &db::clients::Client) { - use chrono::Local; - - println!("{}", "=".repeat(60)); - println!("UUID: {}", client.id); - println!("Client ID: {}", client.client_id); - println!("Secret Hash: {}...", &client.secret_hash[..20.min(client.secret_hash.len())]); - println!("Webhook URL: {}", client.webhook_url); - println!("Verifier URL: {}", client.verifier_url); - println!("Verifier API Path: {}", client.verifier_management_api_path); - println!("Redirect URI: {}", client.redirect_uri.as_deref().unwrap_or("(not set)")); - println!("Accepted Issuer DIDs: {}", client.accepted_issuer_dids.as_deref().unwrap_or("(not set)")); - println!("Created: {}", client.created_at.with_timezone(&Local)); - println!("Updated: {}", client.updated_at.with_timezone(&Local)); -} - -async fn cmd_list_clients(pool: &sqlx::PgPool) -> Result<()> { - let clients = db::clients::list_clients(pool).await?; - - if clients.is_empty() { - println!("No clients registered."); - return Ok(()); - } - - let total = clients.len(); - - for (i, client) in clients.iter().enumerate() { - if i > 0 { - println!(); - } - print_client_details(&client); - } - - println!(); - println!("{}", "=".repeat(60)); - println!("Total clients: {}", total); - - Ok(()) -} - -async fn cmd_show_client(pool: &sqlx::PgPool, client_id: &str) -> Result<()> { - let client = db::clients::get_client_by_id(pool, client_id) - .await? - .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; - - print_client_details(&client); - - Ok(()) -} - -async fn cmd_create_client( - pool: &sqlx::PgPool, - client_id: &str, - secret: &str, - webhook_url: &str, - verifier_url: &str, - verifier_api_path: Option<&str>, - redirect_uri: Option<&str>, - accepted_issuer_dids: Option<&str>, -) -> Result<()> { - let client = db::clients::register_client( - pool, - client_id, - secret, - webhook_url, - verifier_url, - verifier_api_path, - redirect_uri, - accepted_issuer_dids, - ) - .await - .context("Failed to create client")?; - - println!("Client created successfully."); - println!(); - print_client_details(&client); - - Ok(()) -} - -async fn cmd_update_client( - pool: &sqlx::PgPool, - client_id: &str, - webhook_url: Option<&str>, - verifier_url: Option<&str>, - verifier_api_path: Option<&str>, - redirect_uri: Option<&str>, - accepted_issuer_dids: Option<&str>, -) -> Result<()> { - if webhook_url.is_none() && verifier_url.is_none() && verifier_api_path.is_none() - && redirect_uri.is_none() && accepted_issuer_dids.is_none() { - anyhow::bail!("No fields to update. Specify at least one of: --webhook-url, --verifier-url, --verifier-api-path, --redirect-uri, --accepted-issuer-dids"); - } - - let client = db::clients::get_client_by_id(pool, client_id) - .await? - .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; - - let updated = db::clients::update_client( - pool, - client.id, - webhook_url, - verifier_url, - verifier_api_path, - redirect_uri, - accepted_issuer_dids, - ) - .await - .context("Failed to update client")?; - - println!("Client updated successfully."); - println!(); - print_client_details(&updated); - - Ok(()) -} - -async fn cmd_delete_client(pool: &sqlx::PgPool, client_id: &str, skip_confirm: bool) -> Result<()> { - let client = db::clients::get_client_by_id(pool, client_id) - .await? - .ok_or_else(|| anyhow::anyhow!("Client not found: {}", client_id))?; - - if !skip_confirm { - println!("WARNING: This will delete client '{}' and ALL associated data:", client_id); - println!(" - All sessions"); - println!(" - All tokens"); - println!(" - All pending webhooks"); - println!(); - print!("Type 'yes' to confirm: "); - - use std::io::{self, Write}; - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - if input.trim() != "yes" { - println!("Aborted."); - return Ok(()); - } - } - - let deleted = db::clients::delete_client(pool, client.id).await?; - - if deleted { - println!("Client '{}' deleted successfully.", client_id); - } else { - println!("Client not found (may have been deleted already)."); - } - - Ok(()) -} - -async fn cmd_sync_clients(pool: &sqlx::PgPool, config_file: &str, prune: bool) -> Result<()> { - println!("Loading clients from: {}", config_file); - - let ini = Ini::load_from_file(config_file) - .context("Failed to load configuration file")?; - - let mut synced_client_ids = HashSet::new(); - let mut created_count = 0; - let mut updated_count = 0; - - for (section_name, properties) in ini.iter() { - let section_name = match section_name { - Some(name) => name, - None => continue, - }; - - println!("\nProcessing section: [{}]", section_name); - - let client_id = properties.get("client_id") - .ok_or_else(|| anyhow::anyhow!("Missing client_id in section [{}]", section_name))?; - let client_secret = properties.get("client_secret") - .ok_or_else(|| anyhow::anyhow!("Missing client_secret in section [{}]", section_name))?; - let webhook_url = properties.get("webhook_url") - .ok_or_else(|| anyhow::anyhow!("Missing webhook_url in section [{}]", section_name))?; - let verifier_url = properties.get("verifier_url") - .ok_or_else(|| anyhow::anyhow!("Missing verifier_url in section [{}]", section_name))?; - - let verifier_api_path = properties.get("verifier_management_api_path"); - let redirect_uri = properties.get("redirect_uri"); - let accepted_issuer_dids = properties.get("accepted_issuer_dids"); - - synced_client_ids.insert(client_id.to_string()); - - let existing_client = db::clients::get_client_by_id(pool, client_id).await?; - - match existing_client { - Some(existing) => { - println!(" Client '{}' already exists, updating...", client_id); - db::clients::update_client( - pool, - existing.id, - Some(webhook_url), - Some(verifier_url), - verifier_api_path, - redirect_uri, - accepted_issuer_dids, - ) - .await - .context(format!("Failed to update client '{}'", client_id))?; - updated_count += 1; - println!(" Updated client '{}'", client_id); - } - None => { - println!(" Creating new client '{}'...", client_id); - db::clients::register_client( - pool, - client_id, - client_secret, - webhook_url, - verifier_url, - verifier_api_path, - redirect_uri, - accepted_issuer_dids, - ) - .await - .context(format!("Failed to create client '{}'", client_id))?; - created_count += 1; - println!(" Created client '{}'", client_id); - } - } - } - - if prune { - println!("\nPruning clients not in configuration file..."); - let all_clients = db::clients::list_clients(pool).await?; - let mut pruned_count = 0; - - for client in all_clients { - if !synced_client_ids.contains(&client.client_id) { - println!(" Deleting client '{}'...", client.client_id); - db::clients::delete_client(pool, client.id).await?; - pruned_count += 1; - } - } - println!("Pruned {} client(s)", pruned_count); - } - - println!("\nSync complete:"); - println!(" Created: {}", created_count); - println!(" Updated: {}", updated_count); - - Ok(()) -} diff --git a/oauth2_gateway/src/bin/webhook_worker.rs b/oauth2_gateway/src/bin/webhook_worker.rs @@ -1,96 +0,0 @@ -//! Webhook worker daemon binary -//! -//! Background process that delivers webhooks to client endpoints. -//! Uses PostgreSQL NOTIFY/LISTEN for event-driven wake-up. -//! -//! Usage: -//! webhook-worker -c config.ini # Run normally -//! webhook-worker -c config.ini -t # Test mode (exit when idle) - -use oauth2_gateway::{config::Config, db, worker}; -use anyhow::Result; -use clap::Parser; -use tokio::signal; -use tokio::sync::watch; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -#[derive(Parser, Debug)] -#[command(name = "webhook-worker")] -#[command(version)] -#[command(about = "Background process that executes webhooks")] -struct Args { - /// Configuration file path - #[arg(short = 'c', long = "config", value_name = "FILE")] - config: String, - - /// Run in test mode (exit when idle) - #[arg(short = 't', long = "test")] - test_mode: bool, -} - -#[tokio::main] -async fn main() -> Result<()> { - // Init logging, tracing - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "oauth2_gateway=info,tower_http=info,sqlx=warn".into()), - ) - .with( - tracing_subscriber::fmt::layer() - .compact() - .with_ansi(false) - .with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()), - ) - .init(); - - let args = Args::parse(); - - tracing::info!("Starting webhook worker v{}", env!("CARGO_PKG_VERSION")); - tracing::info!("Loading configuration from: {}", args.config); - - let config = Config::from_file(&args.config)?; - - tracing::info!("Connecting to database: {}", config.database.url); - let pool = db::create_pool(&config.database.url).await?; - - // Set up shutdown signal handling - let (shutdown_tx, shutdown_rx) = watch::channel(false); - - // Spawn signal handler task - tokio::spawn(async move { - let ctrl_c = async { - signal::ctrl_c() - .await - .expect("Failed to install Ctrl+C handler"); - }; - - #[cfg(unix)] - let terminate = async { - signal::unix::signal(signal::unix::SignalKind::terminate()) - .expect("Failed to install SIGTERM handler") - .recv() - .await; - }; - - #[cfg(not(unix))] - let terminate = std::future::pending::<()>(); - - tokio::select! { - _ = ctrl_c => { - tracing::info!("Received Ctrl+C, initiating shutdown"); - } - _ = terminate => { - tracing::info!("Received SIGTERM, initiating shutdown"); - } - } - - let _ = shutdown_tx.send(true); - }); - - // Run the worker - worker::run_worker(pool, &config.webhook_worker, args.test_mode, shutdown_rx).await?; - - tracing::info!("Webhook worker exited cleanly"); - Ok(()) -} -\ No newline at end of file diff --git a/oauth2_gateway/src/db/sessions.rs b/oauth2_gateway/src/db/sessions.rs @@ -1,332 +0,0 @@ -// Database operations for verification_sessions table - -use sqlx::PgPool; -use anyhow::Result; -use uuid::Uuid; -use chrono::{DateTime, Utc}; - -/// Status of a verification session -#[derive(Debug, Clone, sqlx::Type, serde::Serialize, serde::Deserialize, PartialEq)] -#[sqlx(type_name = "varchar")] -pub enum SessionStatus { - #[sqlx(rename = "pending")] - Pending, - #[sqlx(rename = "authorized")] - Authorized, - #[sqlx(rename = "verified")] - Verified, - #[sqlx(rename = "completed")] - Completed, - #[sqlx(rename = "expired")] - Expired, - #[sqlx(rename = "failed")] - Failed, -} - -/// Verification session record used in /setup endpoint -#[derive(Debug, Clone, sqlx::FromRow)] -pub struct VerificationSession { - pub id: Uuid, - pub client_id: Uuid, - pub nonce: String, - pub scope: String, - pub redirect_uri: Option<String>, - pub state: Option<String>, - pub verification_url: Option<String>, - pub request_id: Option<String>, - pub verifier_nonce: Option<String>, - pub status: SessionStatus, - pub created_at: DateTime<Utc>, - pub authorized_at: Option<DateTime<Utc>>, - pub verified_at: Option<DateTime<Utc>>, - pub completed_at: Option<DateTime<Utc>>, - pub failed_at: Option<DateTime<Utc>>, - pub expires_at: DateTime<Utc>, -} - -/// Authorize record data used in /authorize endpoint -#[derive(Debug, Clone)] -pub struct AuthorizeSessionData { - // Session fields - pub session_id: Uuid, - pub status: SessionStatus, - pub expires_at: DateTime<Utc>, - pub scope: String, - pub redirect_uri: Option<String>, - pub state: Option<String>, - pub verification_url: Option<String>, - pub verification_deeplink: Option<String>, - pub request_id: Option<String>, - pub verifier_nonce: Option<String>, - // Client fields - pub verifier_url: String, - pub verifier_management_api_path: String, -} - -/// Notification record data used in /notification webhook endpoint -#[derive(Debug, Clone)] -pub struct NotificationSessionData { - // Session fields - pub session_id: Uuid, - pub nonce: String, - pub status: SessionStatus, - pub redirect_uri: Option<String>, - pub state: Option<String>, - // Client fields - pub client_id: Uuid, - pub webhook_url: String, - pub verifier_url: String, - pub verifier_management_api_path: String, -} - - -/// Create a new verification session -/// -/// Returns None if client_id doesn't exist -/// -/// Used by the /setup endpoint -pub async fn create_session( - pool: &PgPool, - client_id: &str, - nonce: &str, - expires_in_minutes: i64, -) -> Result<Option<VerificationSession>> { - let session = sqlx::query_as::<_, VerificationSession>( - r#" - INSERT INTO oauth2gw.verification_sessions (client_id, nonce, scope, - expires_at, status) - SELECT c.id, $1, '', NOW() + $2 * INTERVAL '1 minute', 'pending' - FROM oauth2gw.clients c - WHERE c.client_id = $3 - RETURNING - id, client_id, nonce, scope, redirect_uri, state, verification_url, - request_id, verifier_nonce, status, created_at, authorized_at, - verified_at, completed_at, failed_at, expires_at - "# - ) - .bind(nonce) - .bind(expires_in_minutes) - .bind(client_id) - .fetch_optional(pool) - .await?; - - Ok(session) -} - -/// Fetch session and client data for /authorize endpoint -/// -/// Returns all data needed for backend validation and idempotent responses. -/// Updates the session scope with the provided scope parameter. -/// -/// Used by the /authorize endpoint -pub async fn get_session_for_authorize( - pool: &PgPool, - nonce: &str, - client_id: &str, - scope: &str, - redirect_uri: &str, - state: &str, -) -> Result<Option<AuthorizeSessionData>> { - let result = sqlx::query( - r#" - UPDATE oauth2gw.verification_sessions s - SET scope = $3, redirect_uri = $4, state = $5 - FROM oauth2gw.clients c - WHERE s.client_id = c.id - AND s.nonce = $1 - AND c.client_id = $2 - RETURNING - s.id AS session_id, - s.status, - s.expires_at, - s.scope, - s.redirect_uri, - s.state, - s.verification_url, - s.verification_deeplink, - s.request_id, - s.verifier_nonce, - c.verifier_url, - c.verifier_management_api_path - "# - ) - .bind(nonce) - .bind(client_id) - .bind(scope) - .bind(redirect_uri) - .bind(state) - .fetch_optional(pool) - .await?; - - Ok(result.map(|row: sqlx::postgres::PgRow| { - use sqlx::Row; - AuthorizeSessionData { - session_id: row.get("session_id"), - status: row.get("status"), - expires_at: row.get("expires_at"), - scope: row.get("scope"), - redirect_uri: row.get("redirect_uri"), - state: row.get("state"), - verification_url: row.get("verification_url"), - verification_deeplink: row.get("verification_deeplink"), - request_id: row.get("request_id"), - verifier_nonce: row.get("verifier_nonce"), - verifier_url: row.get("verifier_url"), - verifier_management_api_path: row.get("verifier_management_api_path"), - } - })) -} - -/// Fetch session and client data for /notification webhook -/// -/// Returns all data needed for backend validation and client notification. -/// -/// Used by the /notification endpoint (incoming webhook from Swiyu) -pub async fn get_session_for_notification( - pool: &PgPool, - request_id: &str, -) -> Result<Option<NotificationSessionData>> { - let result = sqlx::query( - r#" - UPDATE oauth2gw.verification_sessions s - SET status = s.status - FROM oauth2gw.clients c - WHERE s.client_id = c.id - AND s.request_id = $1 - RETURNING - s.id AS session_id, - s.nonce, - s.status, - s.redirect_uri, - s.state, - c.id AS client_id, - c.webhook_url, - c.verifier_url, - c.verifier_management_api_path - "# - ) - .bind(request_id) - .fetch_optional(pool) - .await?; - - Ok(result.map(|row: sqlx::postgres::PgRow| { - use sqlx::Row; - NotificationSessionData { - session_id: row.get("session_id"), - nonce: row.get("nonce"), - status: row.get("status"), - redirect_uri: row.get("redirect_uri"), - state: row.get("state"), - client_id: row.get("client_id"), - webhook_url: row.get("webhook_url"), - verifier_url: row.get("verifier_url"), - verifier_management_api_path: row.get("verifier_management_api_path"), - } - })) -} - -/// Data returned after updating session to authorized -#[derive(Debug, Clone)] -pub struct AuthorizedSessionResult { - pub request_id: String, - pub verification_url: String, -} - -/// Update session to authorized with verifier response data -/// -/// Called after successful POST to Swiyu Verifier. -/// Sets status to 'authorized' and stores verification_url, request_id, verifier_nonce. -/// Returns the request_id (verification_id) and verification_url. -/// -/// Used by the /authorize endpoint -pub async fn update_session_authorized( - pool: &PgPool, - session_id: Uuid, - verification_url: &str, - verification_deeplink: Option<&str>, - request_id: &str, - verifier_nonce: Option<&str>, -) -> Result<AuthorizedSessionResult> { - let result = sqlx::query( - r#" - UPDATE oauth2gw.verification_sessions - SET status = 'authorized', - verification_url = $1, - verification_deeplink = $2, - request_id = $3, - verifier_nonce = $4, - authorized_at = NOW() - WHERE id = $5 - RETURNING request_id, verification_url - "# - ) - .bind(verification_url) - .bind(verification_deeplink) - .bind(request_id) - .bind(verifier_nonce) - .bind(session_id) - .fetch_one(pool) - .await?; - - use sqlx::Row; - Ok(AuthorizedSessionResult { - request_id: result.get("request_id"), - verification_url: result.get("verification_url"), - }) -} - -/// Atomically update session to verified, create authorization code, and queue webhook -/// -/// Returns the generated authorization code on success. -pub async fn verify_session_and_queue_notification( - pool: &PgPool, - session_id: Uuid, - status: SessionStatus, - authorization_code: &str, - code_expires_in_minutes: i64, - client_id: Uuid, - webhook_url: &str, - webhook_body: &str, - verifiable_credential: Option<&serde_json::Value>, -) -> Result<String> { - let timestamp_field = match status { - SessionStatus::Verified => "verified_at", - SessionStatus::Failed => "failed_at", - _ => return Err(anyhow::anyhow!("Invalid status for notification: must be Verified or Failed")), - }; - - let query = format!( - r#" - WITH updated_session AS ( - UPDATE oauth2gw.verification_sessions - SET status = $1, {} = NOW(), verifiable_credential = $8 - WHERE id = $2 - RETURNING id - ), - inserted_code AS ( - INSERT INTO oauth2gw.authorization_codes (session_id, code, expires_at) - VALUES ($2, $3, NOW() + $4 * INTERVAL '1 minute') - RETURNING code - ) - INSERT INTO oauth2gw.notification_pending_webhooks - (session_id, client_id, url, http_method, body, next_attempt) - VALUES ($2, $5, $6, 'GET', $7, 0) - RETURNING (SELECT code FROM inserted_code) - "#, - timestamp_field - ); - - let code = sqlx::query_scalar::<_, String>(&query) - .bind(status) - .bind(session_id) - .bind(authorization_code) - .bind(code_expires_in_minutes) - .bind(client_id) - .bind(webhook_url) - .bind(webhook_body) - .bind(verifiable_credential) - .fetch_one(pool) - .await?; - - Ok(code) -} diff --git a/oauth2_gateway/src/handlers.rs b/oauth2_gateway/src/handlers.rs @@ -1,819 +0,0 @@ -use axum::{ - Json, - extract::{Path, Query, State}, - http::{StatusCode, header}, - response::IntoResponse, - Form, -}; -use chrono::Utc; -use serde_json::json; - -use crate::{crypto, db::sessions::SessionStatus, models::*, state::AppState}; - -// Health check endpoint -pub async fn health_check() -> impl IntoResponse { - tracing::info!("Received Health Request"); - Json(json!({ - "status": "healthy", - "service": "oauth2-gateway", - })) -} - -// POST /setup/{clientId} -pub async fn setup( - State(state): State<AppState>, - Path(client_id): Path<String>, - headers: axum::http::HeaderMap, -) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { - tracing::info!("Setup request for client: {}", client_id); - - let auth_header = headers - .get(header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()); - - let bearer_token = match auth_header { - Some(h) if h.starts_with("Bearer ") => &h[7..], - _ => { - tracing::warn!( - "Missing or malformed Authorization header for client: {}", - client_id - ); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse::new("unauthorized")), - )); - } - }; - - let secret_hash = crate::db::clients::get_client_secret_hash(&state.pool, &client_id) - .await - .map_err(|e| { - tracing::error!("DB error fetching client secret: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - let secret_hash = match secret_hash { - Some(hash) => hash, - None => { - tracing::warn!("Client not found: {}", client_id); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse::new("unauthorized")), - )); - } - }; - - let is_valid = bcrypt::verify(bearer_token, &secret_hash).map_err(|e| { - tracing::error!("Bcrypt verification error: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - if !is_valid { - tracing::warn!("Invalid bearer token for client: {}", client_id); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse::new("unauthorized")), - )); - } - - let nonce = crypto::generate_nonce(state.config.crypto.nonce_bytes); - - tracing::debug!("Generated nonce: {}", nonce); - - let session = crate::db::sessions::create_session(&state.pool, &client_id, &nonce, 15) - .await - .map_err(|e| { - tracing::error!("Failed to create session: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - let session = match session { - Some(s) => s, - None => { - tracing::warn!("Client not found: {}", client_id); - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse::new("client_not_found")), - )); - } - }; - - tracing::info!( - "Created session {} for client {} with nonce {}", - session.id, - client_id, - nonce - ); - - Ok((StatusCode::OK, Json(SetupResponse { nonce }))) -} - -// GET /authorize/{nonce}?response_type=code&client_id={client_id}&redirect_uri={uri}&state={state} -pub async fn authorize( - State(state): State<AppState>, - Path(nonce): Path<String>, - Query(params): Query<AuthorizeQuery>, -) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { - tracing::info!( - "Authorize request for client: {}, nonce: {}, state: {}, redirect_uri: {}, scope: {}", - params.client_id, - nonce, - params.state, - params.redirect_uri, - params.scope - ); - - if params.response_type != "code" { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse::new("invalid_request")), - )); - } - - let session_data = crate::db::sessions::get_session_for_authorize( - &state.pool, - &nonce, - &params.client_id, - &params.scope, - &params.redirect_uri, - &params.state, - ) - .await - .map_err(|e| { - tracing::error!("DB error in authorize: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - let data = match session_data { - Some(d) => d, - None => { - tracing::warn!("Session not found for nonce: {}", nonce); - return Err(( - StatusCode::NOT_FOUND, - Json(ErrorResponse::new("session_not_found")), - )); - } - }; - - // Backend validation - if data.expires_at < Utc::now() { - tracing::warn!("Session expired: {}", data.session_id); - return Err(( - StatusCode::GONE, - Json(ErrorResponse::new("session_expired")), - )); - } - - // Check status for idempotency - match data.status { - SessionStatus::Authorized => { - tracing::info!( - "Session {} already authorized, returning cached response", - data.session_id - ); - - let verification_id = data - .request_id - .and_then(|id| uuid::Uuid::parse_str(&id).ok()) - .unwrap_or(uuid::Uuid::nil()); - - return Ok(PrettyJson(AuthorizeResponse { - verification_id, - verification_url: data.verification_url.clone().unwrap_or_default(), - verification_deeplink: data.verification_deeplink, - state: params.state.clone() - })); - } - - SessionStatus::Pending => { - // Proceed with authorization - } - - _ => { - tracing::warn!( - "Session {} in invalid status: {:?}", - data.session_id, - data.status - ); - return Err(( - StatusCode::CONFLICT, - Json(ErrorResponse::new("invalid_session_status")), - )); - } - } - - // Build presentation definition from scope - let presentation_definition = build_presentation_definition(&data.scope); - - // Call Swiyu Verifier - let verifier_url = format!("{}{}", data.verifier_url, data.verifier_management_api_path); - - let verifier_request = SwiyuCreateVerificationRequest { - accepted_issuer_dids: default_accepted_issuer_dids(), - trust_anchors: None, - jwt_secured_authorization_request: Some(true), - response_mode: ResponseMode::DirectPost, - response_type: "vp_token".to_string(), - presentation_definition, - configuration_override: ConfigurationOverride::default(), - dcql_query: None, - }; - - tracing::debug!( - "Swiyu verifier request: {}", - serde_json::to_string_pretty(&verifier_request).unwrap() - ); - tracing::debug!("Calling Swiyu verifier at: {}", verifier_url); - - let verifier_response = state - .http_client - .post(&verifier_url) - .json(&verifier_request) - .send() - .await - .map_err(|e| { - tracing::error!("Failed to call Swiyu verifier: {}", e); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse::new("verifier_unavailable")), - ) - })?; - - if !verifier_response.status().is_success() { - let status = verifier_response.status(); - let body = verifier_response.text().await.unwrap_or_default(); - tracing::error!("Swiyu verifier returned error {}: {}", status, body); - return Err(( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse::new("verifier_error")), - )); - } - - let swiyu_response: SwiyuManagementResponse = verifier_response.json().await.map_err(|e| { - tracing::error!("Failed to parse Swiyu response: {}", e); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse::new("verifier_invalid_response")), - ) - })?; - - // Update session with verifier data - let result = crate::db::sessions::update_session_authorized( - &state.pool, - data.session_id, - &swiyu_response.verification_url, - swiyu_response.verification_deeplink.as_deref(), - &swiyu_response.id.to_string(), - swiyu_response.request_nonce.as_deref(), - ) - .await - .map_err(|e| { - tracing::error!("Failed to update session: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - tracing::info!( - "Session {} authorized, verification_id: {}", - data.session_id, - swiyu_response.id - ); - - Ok(PrettyJson(AuthorizeResponse { - verification_id: swiyu_response.id, - verification_url: result.verification_url, - verification_deeplink: swiyu_response.verification_deeplink, - state: params.state.clone() - })) -} - -/// Build a presentation definition from a space-delimited scope string -/// -/// Example: "age_over_18" or "first_name last_name" -fn build_presentation_definition(scope: &str) -> PresentationDefinition { - use std::collections::HashMap; - use uuid::Uuid; - - let attributes: Vec<&str> = scope.split_whitespace().collect(); - - tracing::debug!( - "Building presentation definition for attributes: {:?}", - attributes - ); - - // First field: $.vct with filter for credential type - let vct_field = Field { - path: vec!["$.vct".to_string()], - id: None, - name: None, - purpose: None, - filter: Some(Filter { - filter_type: "string".to_string(), - const_value: Some("betaid-sdjwt".to_string()), - }), - }; - - // Attribute fields from scope - let mut fields: Vec<Field> = vec![vct_field]; - for attr in &attributes { - fields.push(Field { - path: vec![format!("$.{}", attr)], - id: None, - name: None, - purpose: None, - filter: None, - }); - } - - let mut format = HashMap::new(); - format.insert( - "vc+sd-jwt".to_string(), - FormatAlgorithm { - sd_jwt_alg_values: vec!["ES256".to_string()], - kb_jwt_alg_values: vec!["ES256".to_string()], - }, - ); - - let input_descriptor = InputDescriptor { - id: Uuid::new_v4().to_string(), - name: None, - purpose: None, - format: Some(format), - constraints: Constraint { fields }, - }; - - PresentationDefinition { - id: Uuid::new_v4().to_string(), - name: Some("Over 18 Verification".to_string()), - purpose: Some("Verify age is over 18".to_string()), - format: None, // No format at top level - input_descriptors: vec![input_descriptor], - } -} - -// POST /token -pub async fn token( - State(state): State<AppState>, - Form(request): Form<TokenRequest>, -) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { - tracing::info!("Token request for code: {}", request.code); - - // Validate grant_type - if request.grant_type != "authorization_code" { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse::new("unsupported_grant_type")), - )); - } - - // Authenticate client - let client = crate::db::clients::authenticate_client( - &state.pool, - &request.client_id, - &request.client_secret, - ) - .await - .map_err(|e| { - tracing::error!("DB error during client authentication: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - let client = match client { - Some(c) => c, - None => { - tracing::warn!("Client authentication failed for {}", request.client_id); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse::new("invalid_client")), - )); - } - }; - - // Fetch code (idempotent) - let code_data = - crate::db::authorization_codes::get_code_for_token_exchange(&state.pool, &request.code) - .await - .map_err(|e| { - tracing::error!("DB error in token exchange: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - let data = match code_data { - Some(d) => d, - None => { - tracing::warn!("Authorization code not found or expired: {}", request.code); - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse::new("invalid_grant")), - )); - } - }; - - // Verify the authorization code belongs to the client - if data.client_id != client.id { - tracing::warn!( - "Authorization code {} does not belong to the client {}", - request.code, - request.client_id - ); - - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse::new("invalid_grant")), - )); - } - - // Check for existing token - if let Some(existing_token) = data.existing_token { - tracing::info!( - "Token already exists for session {}, returning cached response", - data.session_id - ); - return Ok(( - StatusCode::OK, - Json(TokenResponse { - access_token: existing_token, - token_type: "Bearer".to_string(), - expires_in: 3600, - }), - )); - } - - // Check if code was already used - if data.was_already_used { - tracing::warn!("Authorization code {} was already used", request.code); - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse::new("invalid_grant")), - )); - } - - // Validate session status - if data.session_status != SessionStatus::Verified { - tracing::warn!( - "Session {} not in verified status: {:?}", - data.session_id, - data.session_status - ); - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse::new("invalid_grant")), - )); - } - - // Generate new token and complete session - let access_token = crypto::generate_token(state.config.crypto.token_bytes); - let token = crate::db::tokens::create_token_and_complete_session( - &state.pool, - data.session_id, - &access_token, - 3600, // 1 hour - ) - .await - .map_err(|e| { - tracing::error!("Failed to create token: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - tracing::info!("Token created for session {}", data.session_id); - - Ok(( - StatusCode::OK, - Json(TokenResponse { - access_token: token.token, - token_type: "Bearer".to_string(), - expires_in: 3600, - }), - )) -} - -// GET /info -pub async fn info( - State(state): State<AppState>, - headers: axum::http::HeaderMap, -) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> { - tracing::info!("Info request received"); - - // Extract token from Authorization header - let auth_header = headers - .get(header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()); - - let token = match auth_header { - Some(h) if h.starts_with("Bearer ") => &h[7..], - _ => { - tracing::warn!("Missing or malformed Authorization header"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse::new("invalid_token")), - )); - } - }; - - // Fetch token with session data (idempotent) - let token_data = crate::db::tokens::get_token_with_session(&state.pool, token) - .await - .map_err(|e| { - tracing::error!("DB error in info: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse::new("internal_error")), - ) - })?; - - let data = match token_data { - Some(d) => d, - None => { - tracing::warn!("Token not found or expired"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse::new("invalid_token")), - )); - } - }; - - // Validate token - if data.revoked { - tracing::warn!("Token {} is revoked", data.token_id); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse::new("invalid_token")), - )); - } - - if data.session_status != SessionStatus::Completed { - tracing::warn!("Session not completed: {:?}", data.session_status); - return Err(( - StatusCode::UNAUTHORIZED, - Json(ErrorResponse::new("invalid_token")), - )); - } - - // Return verifiable credential - let credential = VerifiableCredential { - data: data.verifiable_credential.unwrap_or(json!({})), - }; - - tracing::info!("Returning credential for token {}", data.token_id); - - Ok((StatusCode::OK, Json(credential))) -} - -// POST /notification -// Always returns 200 OK to Swiyu - errors are logged internally -pub async fn notification_webhook( - State(state): State<AppState>, - Json(webhook): Json<NotificationRequest>, -) -> impl IntoResponse { - tracing::info!( - "Webhook received from Swiyu: verification_id={}, timestamp={}", - webhook.verification_id, - webhook.timestamp - ); - - // Lookup session by request_id (verification_id) - let session_data = match crate::db::sessions::get_session_for_notification( - &state.pool, - &webhook.verification_id.to_string(), - ) - .await - { - Ok(Some(data)) => data, - Ok(None) => { - tracing::warn!( - "Session not found for verification_id: {}", - webhook.verification_id - ); - return StatusCode::OK; - } - Err(e) => { - tracing::error!("DB error looking up session: {}", e); - return StatusCode::OK; - } - }; - - // Validate session status - if session_data.status != SessionStatus::Authorized { - tracing::warn!( - "Session {} not in authorized status: {:?}", - session_data.session_id, - session_data.status - ); - return StatusCode::OK; - } - - // Call Swiyu verifier to get verification result - let verifier_url = format!( - "{}{}/{}", - session_data.verifier_url, - session_data.verifier_management_api_path, - webhook.verification_id - ); - - tracing::debug!("Fetching verification result from: {}", verifier_url); - - let verifier_response = match state.http_client.get(&verifier_url).send().await { - Ok(resp) => resp, - Err(e) => { - tracing::error!("Failed to call Swiyu verifier: {}", e); - return StatusCode::OK; - } - }; - - if !verifier_response.status().is_success() { - let status = verifier_response.status(); - tracing::error!("Swiyu verifier returned error: {}", status); - return StatusCode::OK; - } - - let swiyu_result: SwiyuManagementResponse = match verifier_response.json().await { - Ok(r) => r, - Err(e) => { - tracing::error!("Failed to parse Swiyu response: {}", e); - return StatusCode::OK; - } - }; - - // Determine status based on verification result - let (new_status, status_str) = match swiyu_result.state { - SwiyuVerificationStatus::Success => (SessionStatus::Verified, "verified"), - SwiyuVerificationStatus::Failed => (SessionStatus::Failed, "failed"), - SwiyuVerificationStatus::Pending => { - tracing::info!( - "Verification {} still pending, ignoring webhook", - webhook.verification_id - ); - return StatusCode::OK; - } - }; - - // Generate authorization code - let authorization_code = crypto::generate_authorization_code(state.config.crypto.authorization_code_bytes); - - // Construct GET request URL: redirect_uri?code=XXX&state=YYY - let redirect_uri = session_data.redirect_uri.as_ref() - .unwrap_or(&session_data.webhook_url); - let oauth_state = session_data.state.as_deref().unwrap_or(""); - - let webhook_url = format!( - "{}?code={}&state={}", - redirect_uri, - authorization_code, - oauth_state - ); - - // Update session, create auth code, and queue webhook (GET request, empty body) - match crate::db::sessions::verify_session_and_queue_notification( - &state.pool, - session_data.session_id, - new_status, - &authorization_code, - 10, // 10 minutes for auth code expiry - session_data.client_id, - &webhook_url, - "", // Empty body for GET request - swiyu_result.wallet_response.as_ref(), - ) - .await - { - Ok(code) => { - tracing::info!( - "Session {} updated to {}, auth code created, webhook queued", - session_data.session_id, - status_str - ); - tracing::debug!("Generated authorization code: {}", code); - } - Err(e) => { - tracing::error!("Failed to update session and queue notification: {}", e); - } - } - - StatusCode::OK -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_build_presentation_definition_single_attribute() { - let scope = "age_over_18"; - let pd = build_presentation_definition(scope); - - // Verify structure - assert!(!pd.id.is_empty()); - assert_eq!(pd.name, Some("Over 18 Verification".to_string())); - assert_eq!(pd.input_descriptors.len(), 1); - - // Verify fields: vct filter + requested attribute - let fields = &pd.input_descriptors[0].constraints.fields; - assert_eq!(fields.len(), 2); - - // First field is vct with filter - assert_eq!(fields[0].path, vec!["$.vct"]); - assert!(fields[0].filter.is_some()); - let filter = fields[0].filter.as_ref().unwrap(); - assert_eq!(filter.filter_type, "string"); - assert_eq!(filter.const_value, Some("betaid-sdjwt".to_string())); - - // Second field is the requested attribute - assert_eq!(fields[1].path, vec!["$.age_over_18"]); - assert!(fields[1].filter.is_none()); - } - - #[test] - fn test_build_presentation_definition_multiple_attributes() { - let scope = "first_name last_name date_of_birth"; - let pd = build_presentation_definition(scope); - - let fields = &pd.input_descriptors[0].constraints.fields; - // vct + 3 attributes = 4 fields - assert_eq!(fields.len(), 4); - - assert_eq!(fields[0].path, vec!["$.vct"]); // vct first - assert_eq!(fields[1].path, vec!["$.first_name"]); - assert_eq!(fields[2].path, vec!["$.last_name"]); - assert_eq!(fields[3].path, vec!["$.date_of_birth"]); - } - - #[test] - fn test_build_presentation_definition_extra_whitespace() { - let scope = "first_name last_name"; - let pd = build_presentation_definition(scope); - - let fields = &pd.input_descriptors[0].constraints.fields; - // split_whitespace handles multiple spaces correctly - // vct + 2 attributes = 3 fields - assert_eq!(fields.len(), 3); - assert_eq!(fields[0].path, vec!["$.vct"]); - assert_eq!(fields[1].path, vec!["$.first_name"]); - assert_eq!(fields[2].path, vec!["$.last_name"]); - } - - #[test] - fn test_build_presentation_definition_empty_scope() { - let scope = ""; - let pd = build_presentation_definition(scope); - - let fields = &pd.input_descriptors[0].constraints.fields; - // Only vct field when scope is empty - assert_eq!(fields.len(), 1); - assert_eq!(fields[0].path, vec!["$.vct"]); - } - - #[test] - fn test_build_presentation_definition_no_top_level_format() { - let scope = "age_over_18"; - let pd = build_presentation_definition(scope); - - // No format at top level - assert!(pd.format.is_none()); - } - - #[test] - fn test_build_presentation_definition_input_descriptor_structure() { - let scope = "age_over_18"; - let pd = build_presentation_definition(scope); - - let descriptor = &pd.input_descriptors[0]; - - // Verify descriptor has valid UUID - assert!(!descriptor.id.is_empty()); - - // Verify no name/purpose at descriptor level - assert!(descriptor.name.is_none()); - assert!(descriptor.purpose.is_none()); - - // Verify format is specified at descriptor level - assert!(descriptor.format.is_some()); - let format = descriptor.format.as_ref().unwrap(); - assert!(format.contains_key("vc+sd-jwt")); - let alg = &format["vc+sd-jwt"]; - assert_eq!(alg.sd_jwt_alg_values, vec!["ES256"]); - assert_eq!(alg.kb_jwt_alg_values, vec!["ES256"]); - } -} diff --git a/oauth2_gateway/src/main.rs b/oauth2_gateway/src/main.rs @@ -1,85 +0,0 @@ -use anyhow::Result; -use axum::{ - Router, - routing::{get, post}, -}; -use clap::Parser; -use oauth2_gateway::{config::Config, db, handlers, state::AppState}; -use std::{fs, os::unix::fs::PermissionsExt}; -use tower_http::trace::TraceLayer; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -#[derive(Parser, Debug)] -#[command(version)] -struct Args { - /// Configuration - #[arg(short = 'c', long = "config", value_name = "FILE")] - config: String, -} - -#[tokio::main] -async fn main() -> Result<()> { - // Init logging, tracing - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "oauth2_gateway=info,tower_http=info,sqlx=warn".into()), - ) - .with( - tracing_subscriber::fmt::layer() - .compact() - .with_ansi(false) - .with_timer(tracing_subscriber::fmt::time::LocalTime::rfc_3339()), - ) - .init(); - - let args = Args::parse(); - - tracing::info!("Starting OAuth2 Gateway v{}", env!("CARGO_PKG_VERSION")); - tracing::info!("Loading configuration from: {}", args.config); - - let config = Config::from_file(&args.config)?; - - tracing::info!("Connecting to database: {}", config.database.url); - let pool = db::create_pool(&config.database.url).await?; - - let state = AppState::new(config.clone(), pool); - - let app = Router::new() - .route("/health", get(handlers::health_check)) - .route("/setup/{client_id}", post(handlers::setup)) - .route("/authorize/{nonce}", get(handlers::authorize)) - .route("/token", post(handlers::token)) - .route("/info", get(handlers::info)) - .route("/notification", post(handlers::notification_webhook)) - .layer(TraceLayer::new_for_http()) - .with_state(state); - - if config.server.is_unix_socket() { - let socket_path = config.server.socket_path.as_ref().unwrap(); - - if std::path::Path::new(socket_path).exists() { - tracing::warn!("Removing existing socket file: {}", socket_path); - std::fs::remove_file(socket_path)?; - } - - let listener = tokio::net::UnixListener::bind(socket_path)?; - let permissions = std::fs::Permissions::from_mode(0o766); - let _ = fs::set_permissions(socket_path, permissions); - - tracing::info!("Server listening on Unix socket: {}", socket_path); - - axum::serve(listener, app).await?; - } else { - let host = config.server.host.as_ref().unwrap(); - let port = config.server.port.unwrap(); - let addr = format!("{}:{}", host, port); - - let listener = tokio::net::TcpListener::bind(&addr).await?; - tracing::info!("Server listening on {}", addr); - - axum::serve(listener, app).await?; - } - - Ok(()) -} diff --git a/oauth2_gateway/src/models.rs b/oauth2_gateway/src/models.rs @@ -1,276 +0,0 @@ -use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use std::collections::HashMap; -use axum::{ - response::{IntoResponse, Response}, - http::{StatusCode, header}, -}; - -pub struct PrettyJson<T>(pub T); - -impl<T> IntoResponse for PrettyJson<T> -where - T: Serialize, -{ - fn into_response(self) -> Response { - match serde_json::to_string_pretty(&self.0) { - Ok(json) => ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/json")], - json, - ).into_response(), - Err(e) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Serialization error: {}", e), - ).into_response(), - } - } -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct SetupResponse { - pub nonce: String, -} - -#[derive(Debug, Deserialize)] -pub struct AuthorizeQuery { - pub response_type: String, - pub client_id: String, - pub redirect_uri: String, - pub state: String, - pub scope: String, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct AuthorizeResponse { - #[serde(rename = "verificationId")] - pub verification_id: Uuid, - pub verification_url: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub verification_deeplink: Option<String>, - pub state: String, -} - -// Token endpoint -// WARING: RFC 6749 also requires: -// - redirect_uri -#[derive(Debug, Deserialize, Serialize)] -pub struct TokenRequest { - pub grant_type: String, - pub code: String, - pub client_id: String, - pub client_secret: String, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct TokenResponse { - pub access_token: String, - pub token_type: String, - pub expires_in: u64, -} - -// Info endpoint -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct VerifiableCredential { - #[serde(flatten)] - pub data: serde_json::Value, -} - -// Notification webhook from Swiyu Verifier -#[derive(Debug, Deserialize, Serialize)] -pub struct NotificationRequest { - pub verification_id: Uuid, - pub timestamp: String, -} - -// Notification payload sent to Client (Exchange, etc.) -#[derive(Debug, Serialize)] -pub struct ClientNotification { - pub nonce: String, - pub status: String, - pub code: String, - pub verification_id: Uuid, - pub timestamp: String, -} - -// Error response -#[derive(Debug, Serialize, Deserialize)] -pub struct ErrorResponse { - pub error: String, -} - -impl ErrorResponse { - pub fn new(error: impl Into<String>) -> Self { - Self { - error: error.into(), - } - } -} - -// Swiyu Verifier API models - -/// Default issuer DID for Swiyu verification -pub fn default_accepted_issuer_dids() -> Vec<String> { - vec!["did:tdw:QmPEZPhDFR4nEYSFK5bMnvECqdpf1tPTPJuWs9QrMjCumw:identifier-reg.trust-infra.swiyu-int.admin.ch:api:v1:did:9a5559f0-b81c-4368-a170-e7b4ae424527".to_string()] -} - -/// Request body for creating a verification with Swiyu Verifier -/// POST /management/api/verifications -#[derive(Debug, Serialize, Deserialize)] -pub struct SwiyuCreateVerificationRequest { - #[serde(default = "default_accepted_issuer_dids")] - pub accepted_issuer_dids: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub trust_anchors: Option<Vec<TrustAnchor>>, - - /// If omitted, Swiyu defaults to true (beta requires true) - #[serde(skip_serializing_if = "Option::is_none")] - pub jwt_secured_authorization_request: Option<bool>, - - /// Response mode: how the wallet sends the response back - /// REQUIRED - will throw NullPointerException if omitted - pub response_mode: ResponseMode, - - /// Response type - must be "vp_token" for VP requests - pub response_type: String, - - pub presentation_definition: PresentationDefinition, - - pub configuration_override: ConfigurationOverride, - - /// Optional - (wallet migration in progress) - #[serde(skip_serializing_if = "Option::is_none")] - pub dcql_query: Option<serde_json::Value>, -} - -/// Trust anchor for credential verification -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct TrustAnchor { - pub did: String, - pub trust_registry_uri: String, -} - -/// Response mode type -#[derive(Debug, Serialize, Deserialize, Clone)] -#[serde(rename_all = "snake_case")] -pub enum ResponseMode { - /// Wallet sends a clear text response - DirectPost, - - /// Wallet sends an encrypted response - #[serde(rename = "direct_post.jwt")] - DirectPostJwt, -} - -/// Configuration override for a specific verification -/// Can be empty object with all fields set to None -#[derive(Debug, Serialize, Deserialize, Clone, Default)] -pub struct ConfigurationOverride { - /// Override for the EXTERNAL_URL - the url the wallet should call - #[serde(skip_serializing_if = "Option::is_none")] - pub external_url: Option<String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub verifier_did: Option<String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub verification_method: Option<String>, - - /// ID of the key in the HSM - #[serde(skip_serializing_if = "Option::is_none")] - pub key_id: Option<String>, - - /// The pin which protects the key in the HSM - #[serde(skip_serializing_if = "Option::is_none")] - pub key_pin: Option<String>, -} - -/// Presentation Definition according to DIF Presentation Exchange -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct PresentationDefinition { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub purpose: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option<HashMap<String, FormatAlgorithm>>, - pub input_descriptors: Vec<InputDescriptor>, -} - -/// Input descriptor describing required credential attributes -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct InputDescriptor { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub purpose: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub format: Option<HashMap<String, FormatAlgorithm>>, - pub constraints: Constraint, -} - -/// Constraints on credential fields -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Constraint { - pub fields: Vec<Field>, -} - -/// Field specification with JSONPath -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Field { - pub path: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub purpose: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub filter: Option<Filter>, -} - -/// Filter for field constraints -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct Filter { - #[serde(rename = "type")] - pub filter_type: String, - #[serde(rename = "const", skip_serializing_if = "Option::is_none")] - pub const_value: Option<String>, -} - -/// Format algorithm specification for SD-JWT -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct FormatAlgorithm { - #[serde(rename = "sd-jwt_alg_values")] - pub sd_jwt_alg_values: Vec<String>, - #[serde(rename = "kb-jwt_alg_values")] - pub kb_jwt_alg_values: Vec<String>, -} - -/// Response from Swiyu Verifier Management API -/// Used for both POST /management/api/verifications and GET /management/api/verifications/{id} -#[derive(Debug, Serialize, Deserialize)] -pub struct SwiyuManagementResponse { - pub id: Uuid, - #[serde(skip_serializing_if = "Option::is_none")] - pub request_nonce: Option<String>, - pub state: SwiyuVerificationStatus, - pub verification_url: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub verification_deeplink: Option<String>, - pub presentation_definition: PresentationDefinition, - #[serde(skip_serializing_if = "Option::is_none")] - pub dcql_query: Option<serde_json::Value>, - #[serde(skip_serializing_if = "Option::is_none")] - pub wallet_response: Option<serde_json::Value>, -} - -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] -#[serde(rename_all = "UPPERCASE")] -pub enum SwiyuVerificationStatus { - Pending, - Success, - Failed, -}