From 50d9f5baa143b52cd3fc0af8cd12823ec3bcec7f Mon Sep 17 00:00:00 2001 From: Simon Felix Seeger <50206768+SFSeeger@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:00:44 +0100 Subject: [PATCH 1/3] feat: Add OICD authentication feat: Add refresh token logic feat: Remove ability to update email & password as oidc user --- .devcontainer/db_init/01-databases.sql | 4 + .devcontainer/docker-compose.yaml | 62 +- .devcontainer/keycloak/realm-export.json | 2259 +++++++++++++++++ .env.dist | 6 + Cargo.lock | 547 +++- Cargo.toml | 3 + Dioxus.toml | 10 +- packages/api/Cargo.toml | 21 +- packages/api/src/lib.rs | 5 - packages/api/src/routes/app_config.rs | 27 + packages/api/src/routes/events/invitations.rs | 2 +- packages/api/src/routes/mod.rs | 2 + packages/api/src/routes/oidc.rs | 153 ++ packages/api/src/routes/users.rs | 49 +- packages/api/src/server/auth.rs | 21 +- packages/api/src/server/auth/middleware.rs | 129 + packages/api/src/server/auth/oidc.rs | 334 +++ packages/api/src/server/constants.rs | 15 + packages/api/src/server/middleware/mod.rs | 2 + .../server/middleware/tracing_middleware.rs | 39 + packages/api/src/server/mod.rs | 6 +- packages/api/src/server/setup.rs | 149 +- packages/api/src/server/todo_lists.rs | 3 +- packages/api/src/server/utils.rs | 21 + packages/api/tests/db_basic_tests.rs | 6 +- packages/entity/src/user.rs | 4 +- .../contexts/app_config_provider.rs | 30 + .../frontend/src/components/contexts/mod.rs | 3 + packages/frontend/src/components/ui/navbar.rs | 13 +- packages/frontend/src/layouts/standard_app.rs | 136 +- packages/frontend/src/views/home.rs | 4 +- packages/frontend/src/views/login.rs | 35 +- packages/frontend/src/views/profile.rs | 12 +- packages/frontend/src/views/sign_up.rs | 9 +- 34 files changed, 3852 insertions(+), 269 deletions(-) create mode 100644 .devcontainer/db_init/01-databases.sql create mode 100644 .devcontainer/keycloak/realm-export.json create mode 100644 packages/api/src/routes/app_config.rs create mode 100644 packages/api/src/routes/oidc.rs create mode 100644 packages/api/src/server/auth/middleware.rs create mode 100644 packages/api/src/server/auth/oidc.rs create mode 100644 packages/api/src/server/constants.rs create mode 100644 packages/api/src/server/middleware/mod.rs create mode 100644 packages/api/src/server/middleware/tracing_middleware.rs create mode 100644 packages/api/src/server/utils.rs create mode 100644 packages/frontend/src/components/contexts/app_config_provider.rs diff --git a/.devcontainer/db_init/01-databases.sql b/.devcontainer/db_init/01-databases.sql new file mode 100644 index 00000000..8a97a0f6 --- /dev/null +++ b/.devcontainer/db_init/01-databases.sql @@ -0,0 +1,4 @@ +CREATE DATABASE IF NOT EXISTS keycloak; +CREATE USER IF NOT EXISTS 'keycloak'@'%' IDENTIFIED BY 'keycloak_password'; +GRANT ALL PRIVILEGES ON keycloak.* TO 'keycloak'@'%'; +FLUSH PRIVILEGES; diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml index 5bc86ed4..3d39bd54 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -1,4 +1,22 @@ services: + traefik: + image: traefik:latest + command: + - "--api.insecure=true" + - "--providers.docker=true" + - "--entrypoints.web.address=:80" + ports: + - "80:80" + - "8080:8080" + labels: + - traefik.http.routers.traefik.rule=Host(`traefik.roommates.local`) + - traefik.http.services.traefik.loadbalancer.server.port=8080 + volumes: + - /var/run/docker.sock:/var/run/docker.sock + networks: + - default + + db: image: mariadb environment: @@ -8,6 +26,10 @@ services: MYSQL_PASSWORD: roommates_password volumes: - db_data:/var/lib/mysql + - ./db-init:/docker-entrypoint-initdb.d + networks: + - default + phpmyadmin: image: phpmyadmin @@ -18,15 +40,43 @@ services: PMA_HOST: db PMA_USER: root PMA_PASSWORD: password + labels: + - traefik.http.routers.phpmyadmin.rule=Host(`db.roommates.local`) + - traefik.http.services.phpmyadmin.loadbalancer.server.port=80 depends_on: - db + networks: + - default + + + keycloak: + image: quay.io/keycloak/keycloak + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: password + KC_DB: mariadb + KC_DB_URL_HOST: db + KC_DB_URL_PORT: 3306 + KC_DB_URL_DATABASE: keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: keycloak_password + command: start-dev --http-enabled=true --http-port=80 --import-realm + volumes: + - ./keycloak:/opt/keycloak/data/import + labels: + - traefik.http.routers.keycloak.rule=Host(`auth.roommates.local`) + - traefik.http.services.keycloak.loadbalancer.server.port=80 + depends_on: + - db + networks: + default: + aliases: + - auth.roommates.local devcontainer: build: context: . dockerfile: Dockerfile - ports: - - "8080:8080" volumes: - ..:/workspace - ~/.gitconfig:/vscode/.gitconfig @@ -34,7 +84,15 @@ services: environment: - TZ=Europe/Berlin command: sleep infinity + labels: + - traefik.http.routers.devcontainer.rule=Host(`roommates.local`) + - traefik.http.services.devcontainer.loadbalancer.server.port=8080 + networks: + - default + +networks: + default: volumes: db_data: diff --git a/.devcontainer/keycloak/realm-export.json b/.devcontainer/keycloak/realm-export.json new file mode 100644 index 00000000..0e61ed24 --- /dev/null +++ b/.devcontainer/keycloak/realm-export.json @@ -0,0 +1,2259 @@ +{ + "id": "1f17b303-c02e-448a-af0c-e9e9fab2cfb2", + "realm": "roommates", + "notBefore": 0, + "defaultSignatureAlgorithm": "RS256", + "revokeRefreshToken": false, + "refreshTokenMaxReuse": 0, + "accessTokenLifespan": 300, + "accessTokenLifespanForImplicitFlow": 900, + "ssoSessionIdleTimeout": 1800, + "ssoSessionMaxLifespan": 36000, + "ssoSessionIdleTimeoutRememberMe": 0, + "ssoSessionMaxLifespanRememberMe": 0, + "offlineSessionIdleTimeout": 2592000, + "offlineSessionMaxLifespanEnabled": false, + "offlineSessionMaxLifespan": 5184000, + "clientSessionIdleTimeout": 0, + "clientSessionMaxLifespan": 0, + "clientOfflineSessionIdleTimeout": 0, + "clientOfflineSessionMaxLifespan": 0, + "accessCodeLifespan": 60, + "accessCodeLifespanUserAction": 300, + "accessCodeLifespanLogin": 1800, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "oauth2DeviceCodeLifespan": 600, + "oauth2DevicePollingInterval": 5, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "registrationEmailAsUsername": true, + "rememberMe": false, + "verifyEmail": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": false, + "editUsernameAllowed": false, + "bruteForceProtected": false, + "permanentLockout": false, + "maxTemporaryLockouts": 0, + "bruteForceStrategy": "MULTIPLE", + "maxFailureWaitSeconds": 900, + "minimumQuickLoginWaitSeconds": 60, + "waitIncrementSeconds": 60, + "quickLoginCheckMilliSeconds": 1000, + "maxDeltaTimeSeconds": 43200, + "failureFactor": 30, + "defaultRole": { + "id": "40472c59-e396-4f98-bc78-dc43e8312cd0", + "name": "default-roles-roommates", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false, + "containerId": "1f17b303-c02e-448a-af0c-e9e9fab2cfb2" + }, + "requiredCredentials": [ + "password" + ], + "otpPolicyType": "totp", + "otpPolicyAlgorithm": "HmacSHA1", + "otpPolicyInitialCounter": 0, + "otpPolicyDigits": 6, + "otpPolicyLookAheadWindow": 1, + "otpPolicyPeriod": 30, + "otpPolicyCodeReusable": false, + "otpSupportedApplications": [ + "totpAppFreeOTPName", + "totpAppGoogleName", + "totpAppMicrosoftAuthenticatorName" + ], + "localizationTexts": {}, + "webAuthnPolicyRpEntityName": "keycloak", + "webAuthnPolicySignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyRpId": "", + "webAuthnPolicyAttestationConveyancePreference": "not specified", + "webAuthnPolicyAuthenticatorAttachment": "not specified", + "webAuthnPolicyRequireResidentKey": "not specified", + "webAuthnPolicyUserVerificationRequirement": "not specified", + "webAuthnPolicyCreateTimeout": 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyAcceptableAaguids": [], + "webAuthnPolicyExtraOrigins": [], + "webAuthnPolicyPasswordlessRpEntityName": "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms": [ + "ES256", + "RS256" + ], + "webAuthnPolicyPasswordlessRpId": "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey": "Yes", + "webAuthnPolicyPasswordlessUserVerificationRequirement": "required", + "webAuthnPolicyPasswordlessCreateTimeout": 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": false, + "webAuthnPolicyPasswordlessAcceptableAaguids": [], + "webAuthnPolicyPasswordlessExtraOrigins": [], + "scopeMappings": [ + { + "clientScope": "offline_access", + "roles": [ + "offline_access" + ] + } + ], + "clientScopeMappings": { + "account": [ + { + "client": "account-console", + "roles": [ + "manage-account", + "view-groups" + ] + } + ] + }, + "clients": [ + { + "id": "3d777827-7be1-4d7e-a1a2-da7ce04bff8a", + "clientId": "account", + "name": "${client_account}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/roommates/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/roommates/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "1dc7f7f1-2f74-4bff-ae1b-8478b100c491", + "clientId": "account-console", + "name": "${client_account-console}", + "rootUrl": "${authBaseUrl}", + "baseUrl": "/realms/roommates/account/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/realms/roommates/account/*" + ], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "f0f4837e-86a9-49c6-bae3-8d81ab3246a0", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": {} + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f023c503-b188-4d27-aee0-cf0304a0e924", + "clientId": "admin-cli", + "name": "${client_admin-cli}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": false, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": true, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "e904b9a8-4879-4378-9de9-462fdf20366a", + "clientId": "app", + "name": "", + "description": "", + "rootUrl": "http://roommates.local", + "adminUrl": "http://roommates.local", + "baseUrl": "http://roommates.local/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": true, + "clientAuthenticatorType": "client-secret", + "secret": "bCRV37ht5sACdcBeSemiXiu2Zqbn7Uj8", + "redirectUris": [ + "/*" + ], + "webOrigins": [ + "http://roommates.local" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": true, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": true, + "protocol": "openid-connect", + "attributes": { + "logout.confirmation.enabled": "false", + "client.secret.creation.time": "1773163892", + "standard.token.exchange.enabled": "false", + "frontchannel.logout.session.required": "true", + "post.logout.redirect.uris": "/", + "oauth2.device.authorization.grant.enabled": "false", + "backchannel.logout.revoke.offline.tokens": "false", + "realm_client": "false", + "oidc.ciba.grant.enabled": "false", + "backchannel.logout.session.required": "true", + "consent.screen.text": "This is a cool test!", + "display.on.consent.screen": "false", + "dpop.bound.access.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "febb4323-f7e1-4c07-905f-8e6d9a4e6ba6", + "clientId": "broker", + "name": "${client_broker}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "f6a69d12-4e2b-41dc-bec5-bda96f37fc43", + "clientId": "realm-management", + "name": "${client_realm-management}", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [], + "webOrigins": [], + "notBefore": 0, + "bearerOnly": true, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": false, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "true", + "post.logout.redirect.uris": "+" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": false, + "nodeReRegistrationTimeout": 0, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + }, + { + "id": "24cb8175-5b7a-4aa7-91ac-fdd80bdd3b2c", + "clientId": "security-admin-console", + "name": "${client_security-admin-console}", + "rootUrl": "${authAdminUrl}", + "baseUrl": "/admin/roommates/console/", + "surrogateAuthRequired": false, + "enabled": true, + "alwaysDisplayInConsole": false, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "/admin/roommates/console/*" + ], + "webOrigins": [ + "+" + ], + "notBefore": 0, + "bearerOnly": false, + "consentRequired": false, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "publicClient": true, + "frontchannelLogout": false, + "protocol": "openid-connect", + "attributes": { + "realm_client": "false", + "client.use.lightweight.access.token.enabled": "true", + "post.logout.redirect.uris": "+", + "pkce.code.challenge.method": "S256" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": true, + "nodeReRegistrationTimeout": 0, + "protocolMappers": [ + { + "id": "a751c4a6-6c4f-46a6-a6c5-e13d744e4817", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + } + ], + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "basic", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "organization", + "offline_access", + "microprofile-jwt" + ] + } + ], + "clientScopes": [ + { + "id": "9e15b5a5-347a-4f5b-bef1-70c3a63cac60", + "name": "acr", + "description": "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "9f37d741-e75d-466d-a83b-61561774cadd", + "name": "acr loa level", + "protocol": "openid-connect", + "protocolMapper": "oidc-acr-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "introspection.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + }, + { + "id": "f15c114c-e2fa-4a78-9ee1-58b38b328226", + "name": "saml_organization", + "description": "Organization Membership", + "protocol": "saml", + "attributes": { + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "d946d26f-b9bc-4ec5-bf5c-ee245cb54849", + "name": "organization", + "protocol": "saml", + "protocolMapper": "saml-organization-membership-mapper", + "consentRequired": false, + "config": {} + } + ] + }, + { + "id": "1061bf84-1dab-45c5-af74-a510e12a12a7", + "name": "service_account", + "description": "Specific scope for a client enabled for service accounts", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "1e465619-57fe-421b-b673-4915c85d0b7e", + "name": "Client Host", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientHost", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientHost", + "jsonType.label": "String" + } + }, + { + "id": "8be62957-6595-428b-b7c1-12f1443ebfbe", + "name": "Client IP Address", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "clientAddress", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "clientAddress", + "jsonType.label": "String" + } + }, + { + "id": "0b2fde1a-d1b3-4525-ae52-298345fff55a", + "name": "Client ID", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "client_id", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "client_id", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "46920377-0255-433d-89da-eeea6014cd32", + "name": "profile", + "description": "OpenID Connect built-in scope: profile", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${profileScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "ffff6533-a5ea-445a-a902-bf0fd154f966", + "name": "gender", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "gender", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "gender", + "jsonType.label": "String" + } + }, + { + "id": "27ee292a-20f9-445b-8380-2cec6eadd3b7", + "name": "picture", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "picture", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "picture", + "jsonType.label": "String" + } + }, + { + "id": "198173d0-93b3-4df8-bd9d-824997250457", + "name": "nickname", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "nickname", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "nickname", + "jsonType.label": "String" + } + }, + { + "id": "47531ddc-9a7e-4881-bdd7-f5d5125ee2d2", + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "id": "5164bb8a-f251-4134-8184-10339ea9cf74", + "name": "full name", + "protocol": "openid-connect", + "protocolMapper": "oidc-full-name-mapper", + "consentRequired": false, + "config": { + "id.token.claim": "true", + "access.token.claim": "true", + "introspection.token.claim": "true", + "userinfo.token.claim": "true" + } + }, + { + "id": "b79598dd-1c1d-4981-b25d-632b10c2a7fa", + "name": "middle name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "middleName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "middle_name", + "jsonType.label": "String" + } + }, + { + "id": "c287a19d-c128-4681-abb1-08e5fad9ce1a", + "name": "zoneinfo", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "zoneinfo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "zoneinfo", + "jsonType.label": "String" + } + }, + { + "id": "bca38159-688b-415b-b409-8fc564f0a2e6", + "name": "website", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "website", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "website", + "jsonType.label": "String" + } + }, + { + "id": "4e286ccb-c5ec-4d0e-b56a-67d7deb935bf", + "name": "locale", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "locale", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "locale", + "jsonType.label": "String" + } + }, + { + "id": "fbc16ccb-127f-4b67-bec8-3d54e5c06861", + "name": "profile", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "profile", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "profile", + "jsonType.label": "String" + } + }, + { + "id": "3e7d6a7b-473f-4770-a048-bafc71b3f8fc", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "944fb638-c21f-481e-88e2-2fc0b278c7f9", + "name": "updated at", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "updatedAt", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "updated_at", + "jsonType.label": "long" + } + }, + { + "id": "1371c2b7-18af-404e-b847-140f5ab67493", + "name": "birthdate", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "birthdate", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "birthdate", + "jsonType.label": "String" + } + }, + { + "id": "f26f0f68-f581-4889-8d1b-979e951678c0", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "0ac76fab-3df1-41d0-ac24-2a78ebcf39b5", + "name": "address", + "description": "OpenID Connect built-in scope: address", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${addressScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "e49d8e0a-55f3-4742-8d36-aec3116d881a", + "name": "address", + "protocol": "openid-connect", + "protocolMapper": "oidc-address-mapper", + "consentRequired": false, + "config": { + "user.attribute.formatted": "formatted", + "user.attribute.country": "country", + "introspection.token.claim": "true", + "user.attribute.postal_code": "postal_code", + "userinfo.token.claim": "true", + "user.attribute.street": "street", + "id.token.claim": "true", + "user.attribute.region": "region", + "access.token.claim": "true", + "user.attribute.locality": "locality" + } + } + ] + }, + { + "id": "ebee3a4b-a80f-43da-b829-3897c1cd9be1", + "name": "basic", + "description": "OpenID Connect scope for add all basic claims to the token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "a23958a4-ff71-473c-b0ce-f53ffc32968a", + "name": "auth_time", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "AUTH_TIME", + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "auth_time", + "jsonType.label": "long" + } + }, + { + "id": "3c41379c-8662-4ac6-9b50-619ec34f2a3c", + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "id": "f1810394-ed36-4e08-8fc0-8755d1c24193", + "name": "phone", + "description": "OpenID Connect built-in scope: phone", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${phoneScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "c986a246-1143-4d60-9f48-459ee150fe23", + "name": "phone number verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumberVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number_verified", + "jsonType.label": "boolean" + } + }, + { + "id": "95ea87b1-44fa-4f9a-86d9-720d42038fb0", + "name": "phone number", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "phoneNumber", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "phone_number", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "9394ae5a-51fb-4242-a390-b8d5efea5433", + "name": "microprofile-jwt", + "description": "Microprofile - JWT built-in scope", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "2a9c4681-ff8b-4db1-8ccd-391d80c33266", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "user.attribute": "foo", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "groups", + "jsonType.label": "String" + } + }, + { + "id": "8d93d0cf-1cab-4a46-95f4-aaf05926ef3d", + "name": "upn", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "upn", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "9b5e6a6f-807e-480c-9b8f-3669231df3dc", + "name": "web-origins", + "description": "OpenID Connect scope for add allowed web origins to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "id": "480a4809-551a-48dc-bbbf-909420185ea7", + "name": "allowed web origins", + "protocol": "openid-connect", + "protocolMapper": "oidc-allowed-origins-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "id": "5da1066f-bcc7-490b-8db2-5da9973e44de", + "name": "email", + "description": "OpenID Connect built-in scope: email", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${emailScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "682d7a0e-ea94-4f2b-8e7a-12f87ef432ff", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "email", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email", + "jsonType.label": "String" + } + }, + { + "id": "c8ee321c-16ed-4547-959a-d0b44a0869b6", + "name": "email verified", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "emailVerified", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "email_verified", + "jsonType.label": "boolean" + } + } + ] + }, + { + "id": "6325671b-fb29-42f3-bb63-848ecf14e754", + "name": "organization", + "description": "Additional claims about the organization a subject belongs to", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "consent.screen.text": "${organizationScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "0dacc6ad-0d93-4d25-a430-bb38792793c6", + "name": "organization", + "protocol": "openid-connect", + "protocolMapper": "oidc-organization-membership-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "multivalued": "true", + "userinfo.token.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "organization", + "jsonType.label": "String" + } + } + ] + }, + { + "id": "0400e7c0-10a4-4819-9c03-6a4c600db8e8", + "name": "role_list", + "description": "SAML role list", + "protocol": "saml", + "attributes": { + "consent.screen.text": "${samlRoleListScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "7e5fd9a4-64fb-422e-89ab-0a83651399ad", + "name": "role list", + "protocol": "saml", + "protocolMapper": "saml-role-list-mapper", + "consentRequired": false, + "config": { + "single": "false", + "attribute.nameformat": "Basic", + "attribute.name": "Role" + } + } + ] + }, + { + "id": "5b5a1255-2552-4837-9644-a7eca3a5bbbb", + "name": "roles", + "description": "OpenID Connect scope for add user roles to the access token", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "false", + "consent.screen.text": "${rolesScopeConsentText}", + "display.on.consent.screen": "true" + }, + "protocolMappers": [ + { + "id": "6fe6179f-fbb3-4808-b441-55a6eb431bc7", + "name": "realm roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "3774d25a-c996-424b-bec3-e02577b66261", + "name": "client roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-client-role-mapper", + "consentRequired": false, + "config": { + "user.attribute": "foo", + "introspection.token.claim": "true", + "access.token.claim": "true", + "claim.name": "resource_access.${client_id}.roles", + "jsonType.label": "String", + "multivalued": "true" + } + }, + { + "id": "7ae1e614-7e45-4952-8664-b228b7801492", + "name": "audience resolve", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-resolve-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + } + } + ] + }, + { + "id": "44eb0e73-a3ea-49fc-8ed0-a10076cd2091", + "name": "offline_access", + "description": "OpenID Connect built-in scope: offline_access", + "protocol": "openid-connect", + "attributes": { + "consent.screen.text": "${offlineAccessScopeConsentText}", + "display.on.consent.screen": "true" + } + } + ], + "defaultDefaultClientScopes": [ + "role_list", + "profile", + "roles", + "email", + "web-origins", + "acr", + "basic", + "saml_organization" + ], + "defaultOptionalClientScopes": [ + "address", + "offline_access", + "organization", + "microprofile-jwt", + "phone" + ], + "browserSecurityHeaders": { + "contentSecurityPolicyReportOnly": "", + "xContentTypeOptions": "nosniff", + "referrerPolicy": "no-referrer", + "xRobotsTag": "none", + "xFrameOptions": "SAMEORIGIN", + "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity": "max-age=31536000; includeSubDomains" + }, + "smtpServer": {}, + "eventsEnabled": false, + "eventsListeners": [ + "jboss-logging" + ], + "enabledEventTypes": [], + "adminEventsEnabled": false, + "adminEventsDetailsEnabled": false, + "identityProviders": [ + { + "alias": "github", + "displayName": "", + "internalId": "8207cfb5-41c4-4c8c-bb82-9a300a49a1b0", + "providerId": "github", + "enabled": true, + "trustEmail": true, + "storeToken": false, + "linkOnly": false, + "hideOnLogin": false, + "config": { + "githubJsonFormat": "false", + "acceptsPromptNoneForwardFromClient": "false", + "clientId": "Ov23liF7SHVIVi6jIEv9", + "disableUserInfo": "false", + "showInAccountConsole": "ALWAYS", + "filteredByClaim": "false", + "syncMode": "IMPORT", + "clientSecret": "**********", + "caseSensitiveOriginalUsername": "false" + }, + "types": [] + } + ], + "identityProviderMappers": [], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "id": "fc04bb55-91d2-48c8-9df0-a86134e59baf", + "name": "Max Clients Limit", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + }, + { + "id": "480962f4-ded7-42aa-8622-ef9ea1f94ef0", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "dabb33f9-1f4c-4e29-8f05-6d7a2665e783", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "authenticated", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "saml-user-property-mapper", + "oidc-address-mapper", + "oidc-usermodel-property-mapper", + "saml-user-attribute-mapper", + "saml-role-list-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-attribute-mapper" + ] + } + }, + { + "id": "fa60f93c-5b1a-4463-9b3a-d1e2756cb9fb", + "name": "Allowed Registration Web Origins", + "providerId": "registration-web-origins", + "subType": "authenticated", + "subComponents": {}, + "config": {} + }, + { + "id": "ee150540-502e-4ce0-bc04-e9d4d566c9f8", + "name": "Allowed Protocol Mapper Types", + "providerId": "allowed-protocol-mappers", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allowed-protocol-mapper-types": [ + "oidc-full-name-mapper", + "saml-role-list-mapper", + "saml-user-attribute-mapper", + "saml-user-property-mapper", + "oidc-usermodel-attribute-mapper", + "oidc-address-mapper", + "oidc-sha256-pairwise-sub-mapper", + "oidc-usermodel-property-mapper" + ] + } + }, + { + "id": "d515cae7-8adf-41e0-945f-4c404ba155b0", + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "host-sending-registration-request-must-match": [ + "true" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "id": "055d3312-1879-48b8-bf0d-a6fc67cde3ab", + "name": "Full Scope Disabled", + "providerId": "scope", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "58698b7d-3f82-4102-bd61-83c7f150e5c8", + "name": "Allowed Client Scopes", + "providerId": "allowed-client-templates", + "subType": "anonymous", + "subComponents": {}, + "config": { + "allow-default-scopes": [ + "true" + ] + } + }, + { + "id": "53d9c847-00bf-4a18-9b89-17c09d01ff2e", + "name": "Consent Required", + "providerId": "consent-required", + "subType": "anonymous", + "subComponents": {}, + "config": {} + }, + { + "id": "ee23cf71-a5ba-4dfa-aaa3-7befaf92f8ef", + "name": "Allowed Registration Web Origins", + "providerId": "registration-web-origins", + "subType": "anonymous", + "subComponents": {}, + "config": {} + } + ], + "org.keycloak.keys.KeyProvider": [ + { + "id": "82d6d067-959a-4dac-9721-a9c57940d7bb", + "name": "rsa-generated", + "providerId": "rsa-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + }, + { + "id": "c66a30c3-aae6-4da9-8389-26bd6ad588c1", + "name": "hmac-generated-hs512", + "providerId": "hmac-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "HS512" + ] + } + }, + { + "id": "6d4f925b-52e4-40e4-b239-99273b10cf1c", + "name": "rsa-enc-generated", + "providerId": "rsa-enc-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ], + "algorithm": [ + "RSA-OAEP" + ] + } + }, + { + "id": "424fecf5-ba78-4a39-a38a-09d65c2b60c0", + "name": "aes-generated", + "providerId": "aes-generated", + "subComponents": {}, + "config": { + "priority": [ + "100" + ] + } + } + ] + }, + "internationalizationEnabled": false, + "authenticationFlows": [ + { + "id": "7dae1a9c-9e57-405f-8b3b-85096ad3d5a3", + "alias": "Account verification options", + "description": "Method with which to verify the existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-email-verification", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Verify Existing Account by Re-authentication", + "userSetupAllowed": false + } + ] + }, + { + "id": "7900e017-5976-4482-9fc2-4bf5186118b3", + "alias": "Browser - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorConfig": "browser-conditional-credential", + "authenticator": "conditional-credential", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "3c582e0f-1747-41fa-8cb7-a58e3be7a578", + "alias": "Browser - Conditional Organization", + "description": "Flow to determine if the organization identity-first login is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "organization", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "7dd85459-db4b-4162-b03b-13f9cbb85428", + "alias": "Direct Grant - Conditional OTP", + "description": "Flow to determine if the OTP is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "ee6342e3-926f-448b-8297-ee5442813b01", + "alias": "First Broker Login - Conditional Organization", + "description": "Flow to determine if the authenticator that adds organization members is to be used", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "idp-add-organization-member", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "0e0aedae-7e75-4eb1-b03b-fa7ce9386b3f", + "alias": "First broker login - Conditional 2FA", + "description": "Flow to determine if any 2FA is required for the authentication", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorConfig": "first-broker-login-conditional-credential", + "authenticator": "conditional-credential", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-otp-form", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "webauthn-authenticator", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-recovery-authn-code-form", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "ef41864d-ad0f-470c-899b-61e0b8a2351a", + "alias": "Handle Existing Account", + "description": "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-confirm-link", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Account verification options", + "userSetupAllowed": false + } + ] + }, + { + "id": "f3e1a00c-6ee8-4a59-b2b3-bcc86f5df0db", + "alias": "Organization", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "dca0e796-e387-43ff-9d46-ede0496c635c", + "alias": "Reset - Conditional OTP", + "description": "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "conditional-user-configured", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-otp", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "a22195a7-02ef-47f7-ac83-67a6079ed141", + "alias": "User creation or linking", + "description": "Flow for the existing/non-existing user alternatives", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "create unique user config", + "authenticator": "idp-create-user-if-unique", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Handle Existing Account", + "userSetupAllowed": false + } + ] + }, + { + "id": "967e88b6-83bc-45ef-9cb2-3307b83acbaa", + "alias": "Verify Existing Account by Re-authentication", + "description": "Reauthentication of existing account", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "idp-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "First broker login - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "7f0fd622-fabb-4a3d-bcdb-d143ebdf7560", + "alias": "browser", + "description": "Browser based authentication", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "auth-spnego", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 26, + "autheticatorFlow": true, + "flowAlias": "Organization", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "forms", + "userSetupAllowed": false + } + ] + }, + { + "id": "5e858aab-c7ee-4a90-9e21-868247850eba", + "alias": "clients", + "description": "Base authentication for clients", + "providerId": "client-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "client-secret", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-secret-jwt", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "client-x509", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 40, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "33e13e33-9da1-4645-b79f-cfc7c88f01d5", + "alias": "direct grant", + "description": "OpenID Connect Resource Owner Grant", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "direct-grant-validate-username", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "direct-grant-validate-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 30, + "autheticatorFlow": true, + "flowAlias": "Direct Grant - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "dc894ec7-0460-4721-aefc-fede5fe1142d", + "alias": "docker auth", + "description": "Used by Docker clients to authenticate against the IDP", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "docker-http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "49272eb1-254a-44aa-8f6d-274bc8e9fd02", + "alias": "first broker login", + "description": "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticatorConfig": "review profile config", + "authenticator": "idp-review-profile", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "User creation or linking", + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 60, + "autheticatorFlow": true, + "flowAlias": "First Broker Login - Conditional Organization", + "userSetupAllowed": false + } + ] + }, + { + "id": "f3c2f3a1-7da3-41e5-ae03-55b9a2041033", + "alias": "forms", + "description": "Username, password, otp and other auth forms.", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 20, + "autheticatorFlow": true, + "flowAlias": "Browser - Conditional 2FA", + "userSetupAllowed": false + } + ] + }, + { + "id": "f00c38a4-7392-4dce-bfcd-9fa417b93074", + "alias": "registration", + "description": "Registration flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-page-form", + "authenticatorFlow": true, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": true, + "flowAlias": "registration form", + "userSetupAllowed": false + } + ] + }, + { + "id": "b95b5cad-a6fa-439f-ba11-ac3d3695bc14", + "alias": "registration form", + "description": "Registration form", + "providerId": "form-flow", + "topLevel": false, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "registration-user-creation", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-password-action", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 50, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-recaptcha-action", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 60, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "registration-terms-and-conditions", + "authenticatorFlow": false, + "requirement": "DISABLED", + "priority": 70, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + }, + { + "id": "820f2c36-5492-4388-9c70-47d32c72e099", + "alias": "reset credentials", + "description": "Reset credentials for a user if they forgot their password or something", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "reset-credentials-choose-user", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-credential-email", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 20, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "reset-password", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 30, + "autheticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticatorFlow": true, + "requirement": "CONDITIONAL", + "priority": 40, + "autheticatorFlow": true, + "flowAlias": "Reset - Conditional OTP", + "userSetupAllowed": false + } + ] + }, + { + "id": "21a59ef2-b643-40f3-a712-2c54adda20b5", + "alias": "saml ecp", + "description": "SAML ECP Profile Authentication Flow", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": true, + "authenticationExecutions": [ + { + "authenticator": "http-basic-authenticator", + "authenticatorFlow": false, + "requirement": "REQUIRED", + "priority": 10, + "autheticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "id": "dd8f218d-12bb-411a-aa16-30ef1282c841", + "alias": "browser-conditional-credential", + "config": { + "credentials": "webauthn-passwordless" + } + }, + { + "id": "a2d50fe3-a054-41a7-b4b5-d61166653720", + "alias": "create unique user config", + "config": { + "require.password.update.after.registration": "false" + } + }, + { + "id": "99c9a4c0-6bb6-4f2a-9084-fee7f837b50d", + "alias": "first-broker-login-conditional-credential", + "config": { + "credentials": "webauthn-passwordless" + } + }, + { + "id": "f6680e63-0c3f-4745-a273-7be572fe86bd", + "alias": "review profile config", + "config": { + "update.profile.on.first.login": "missing" + } + } + ], + "requiredActions": [ + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 10, + "config": {} + }, + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 30, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "UPDATE_EMAIL", + "name": "Update Email", + "providerId": "UPDATE_EMAIL", + "enabled": false, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 120, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 130, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "browserFlow": "browser", + "registrationFlow": "registration", + "directGrantFlow": "direct grant", + "resetCredentialsFlow": "reset credentials", + "clientAuthenticationFlow": "clients", + "dockerAuthenticationFlow": "docker auth", + "firstBrokerLoginFlow": "first broker login", + "attributes": { + "cibaBackchannelTokenDeliveryMode": "poll", + "cibaExpiresIn": "120", + "cibaAuthRequestedUserHint": "login_hint", + "oauth2DeviceCodeLifespan": "600", + "clientOfflineSessionMaxLifespan": "0", + "oauth2DevicePollingInterval": "5", + "clientSessionIdleTimeout": "0", + "parRequestUriLifespan": "60", + "clientSessionMaxLifespan": "0", + "clientOfflineSessionIdleTimeout": "0", + "cibaInterval": "5", + "realmReusableOtpCode": "false" + }, + "keycloakVersion": "26.5.5", + "userManagedAccessAllowed": false, + "organizationsEnabled": false, + "verifiableCredentialsEnabled": false, + "adminPermissionsEnabled": false, + "clientProfiles": { + "profiles": [] + }, + "clientPolicies": { + "policies": [] + } +} diff --git a/.env.dist b/.env.dist index 337ed4b7..0671b38f 100644 --- a/.env.dist +++ b/.env.dist @@ -1 +1,7 @@ DATABASE_URL=mysql://roommates:roommates_password@db:3306/roommates +OIDC_ENABLED=true +OIDC_CLIENT_ID=app +OIDC_CLIENT_SECRET=bCRV37ht5sACdcBeSemiXiu2Zqbn7Uj8 +OIDC_ISSUER_URL=http://auth.roommates.local/realms/roommates +SERVER_URL=http://roommates.local +SIGNUP_ENABLED=1 diff --git a/Cargo.lock b/Cargo.lock index 1e9bbc57..00fff188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,17 +61,21 @@ version = "0.1.0" dependencies = [ "anyhow", "argon2", - "base64", + "base64 0.22.1", "blake3", "dioxus", "dotenvy", "entity", + "jsonwebtoken", + "openidconnect", "regex", "sea-orm", "serde", "serde_json", "time", "tokio", + "tower-cookies", + "tower-sessions", ] [[package]] @@ -205,7 +209,7 @@ checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -294,6 +298,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -540,7 +556,7 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" dependencies = [ - "base64", + "base64 0.22.1", "encoding_rs", ] @@ -888,6 +904,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -925,6 +953,33 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling" version = "0.20.11" @@ -968,6 +1023,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", + "strsim", "syn 2.0.114", ] @@ -1221,7 +1277,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e6ec66749d1556636c5b4f661495565c155a7f78a46d4d007d7478c6bdc288c" dependencies = [ "async-trait", - "base64", + "base64 0.22.1", "bytes", "cocoa", "core-foundation 0.10.1", @@ -1340,7 +1396,7 @@ dependencies = [ "axum", "axum-core", "axum-extra", - "base64", + "base64 0.22.1", "bytes", "ciborium", "const-str", @@ -1401,7 +1457,7 @@ checksum = "cda8b152e85121243741b9d5f2a3d8cb3c47a7b2299e902f98b6a7719915b0a2" dependencies = [ "anyhow", "axum-core", - "base64", + "base64 0.22.1", "ciborium", "dioxus-core", "dioxus-document", @@ -1653,7 +1709,7 @@ dependencies = [ "anyhow", "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "chrono", "ciborium", @@ -1903,6 +1959,50 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1912,6 +2012,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2015,6 +2136,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -2369,6 +2506,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2572,6 +2710,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -2636,7 +2785,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -2706,7 +2855,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", "http", @@ -2878,7 +3027,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -3031,6 +3180,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -3039,6 +3199,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -3086,6 +3248,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -3156,6 +3327,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -3175,7 +3369,7 @@ checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ "cssparser", "html5ever", - "indexmap", + "indexmap 2.13.0", "selectors", ] @@ -3733,6 +3927,26 @@ dependencies = [ "libc", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.5", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc" version = "0.2.7" @@ -3854,6 +4068,37 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.5", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + [[package]] name = "openssl" version = "0.10.75" @@ -3904,6 +4149,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -3937,6 +4191,30 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "pango" version = "0.18.3" @@ -4002,6 +4280,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4264,6 +4552,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4630,6 +4927,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "regex" version = "1.12.2" @@ -4674,7 +4991,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cookie", "cookie_store", @@ -4712,6 +5029,16 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "rfd" version = "0.17.2" @@ -4933,6 +5260,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4965,7 +5316,7 @@ dependencies = [ "derive_more 2.1.1", "futures-util", "inventory", - "itertools", + "itertools 0.14.0", "log", "ouroboros", "pgvector", @@ -4992,7 +5343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3e208f041129ad7962b6951f0b392e9ff97a8337bd8c7022c61e7b02ab29fe0" dependencies = [ "heck 0.5.0", - "itertools", + "itertools 0.14.0", "pluralizer", "proc-macro2", "quote", @@ -5009,7 +5360,7 @@ checksum = "c6a067a2f6f13250f615f0bedb5bc3a6c872fec70776d0b43b43caeaa699e232" dependencies = [ "chrono", "inherent", - "ordered-float", + "ordered-float 4.6.0", "rust_decimal", "sea-query-derive", "serde_json", @@ -5072,6 +5423,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -5138,6 +5503,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde-wasm-bindgen" version = "0.6.5" @@ -5193,6 +5568,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_qs" version = "0.15.0" @@ -5236,6 +5620,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "servo_arc" version = "0.2.0" @@ -5325,6 +5740,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5465,7 +5892,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "chrono", "crc", @@ -5478,7 +5905,7 @@ dependencies = [ "futures-util", "hashbrown 0.15.5", "hashlink", - "indexmap", + "indexmap 2.13.0", "log", "memchr", "native-tls", @@ -5543,7 +5970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "byteorder", "bytes", @@ -5589,7 +6016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "byteorder", "chrono", @@ -5698,6 +6125,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.27.2" @@ -6135,7 +6568,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.13.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -6146,7 +6579,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap", + "indexmap 2.13.0", "serde", "serde_spanned", "toml_datetime 0.6.3", @@ -6159,7 +6592,7 @@ version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap", + "indexmap 2.13.0", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", @@ -6190,6 +6623,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-http" version = "0.6.8" @@ -6230,6 +6679,57 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tower-sessions" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand 0.9.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + [[package]] name = "tracing" version = "0.1.44" @@ -6428,6 +6928,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -7279,7 +7780,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2" dependencies = [ - "base64", + "base64 0.22.1", "block2", "cookie", "crossbeam-channel", diff --git a/Cargo.toml b/Cargo.toml index c1938e9c..420336b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,9 @@ entity = { path = "packages/entity" } form_hooks_derive = { path = "packages/form_hooks/form_hooks_derive" } form_hooks = { path = "packages/form_hooks" } +#[workspace.lints.clippy] +#pedantic = "warn" + [profile.release] opt-level = "z" debug = false diff --git a/Dioxus.toml b/Dioxus.toml index 29a198d9..50d0a3d2 100644 --- a/Dioxus.toml +++ b/Dioxus.toml @@ -31,11 +31,11 @@ script = [] identifier = "io.github.sfseeger.roommates" publisher = "RoomMates" icon = [ - "packages/frontend/assets/dist/app_icons/32x32.png", - "packages/frontend/assets/dist/app_icons/128x128.png", - "packages/frontend/assets/dist/app_icons/128x128@2x.png", - "packages/frontend/assets/dist/app_icons/icon.icns", - "packages/frontend/assets/dist/app_icons/icon.ico", + "assets/dist/app_icons/32x32.png", + "assets/dist/app_icons/128x128.png", + "assets/dist/app_icons/128x128@2x.png", + "assets/dist/app_icons/icon.icns", + "assets/dist/app_icons/icon.ico", ] [android.app] diff --git a/packages/api/Cargo.toml b/packages/api/Cargo.toml index 0264dcb8..ca3c5872 100644 --- a/packages/api/Cargo.toml +++ b/packages/api/Cargo.toml @@ -7,12 +7,12 @@ edition = "2024" dioxus = { workspace = true, features = ["fullstack"] } sea-orm = { workspace = true, features = [ "sqlx-mysql", - "sqlx-sqlite", # I want people to be able to choose between mysql or sqlite. + "sqlx-sqlite", # I want people to be able to choose between mysql or sqlite. "runtime-tokio-native-tls", "macros", "schema-sync", "entity-registry", - "with-time" + "with-time", ], optional = true } serde = { workspace = true } serde_json = { workspace = true } @@ -25,7 +25,22 @@ tokio = { version = "1", features = ["full"], optional = true } base64 = { version = "0.22.1", optional = true } time = { workspace = true } regex = { workspace = true } +tower-cookies = { version = "0.11.0", optional = true } +openidconnect = { version = "4.0.1", optional = true } +tower-sessions = { version = "0.15.0", optional = true } +jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"], optional = true } [features] -server = ["dioxus/server", "dep:sea-orm", "dep:tokio", "dep:argon2", "dep:blake3", "dep:base64"] +server = [ + "dioxus/server", + "dep:sea-orm", + "dep:tokio", + "dep:argon2", + "dep:blake3", + "dep:base64", + "dep:openidconnect", + "dep:tower-cookies", + "dep:tower-sessions", + "dep:jsonwebtoken", +] diff --git a/packages/api/src/lib.rs b/packages/api/src/lib.rs index 93f43dce..3c5ad1c0 100644 --- a/packages/api/src/lib.rs +++ b/packages/api/src/lib.rs @@ -3,8 +3,3 @@ use dioxus::prelude::*; pub mod routes; #[cfg(feature = "server")] pub mod server; - -#[post("/api/echo")] -pub async fn echo(input: String) -> Result { - Ok(input) -} diff --git a/packages/api/src/routes/app_config.rs b/packages/api/src/routes/app_config.rs new file mode 100644 index 00000000..ddb63cc3 --- /dev/null +++ b/packages/api/src/routes/app_config.rs @@ -0,0 +1,27 @@ +use crate::server; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] +pub struct AppConfig { + pub oidc_enabled: bool, + pub oidc_provider_name: Option, + pub signup_enabled: bool, +} + +#[allow(clippy::unused_async)] +#[get("/api/app_config")] +pub async fn get_app_config() -> Result { + use server::constants; + use server::utils::{convert_env_to_bool, get_env_or, parse_env_string}; + + Ok(AppConfig { + oidc_enabled: get_env_or(constants::OIDC_ENABLED_ENV_VAR, false, convert_env_to_bool), + oidc_provider_name: get_env_or( + constants::OIDC_PROVIDER_NAME_ENV_VAR, + None, + parse_env_string, + ), + signup_enabled: get_env_or(constants::SIGNUP_ENABLED_ENV_VAR, true, convert_env_to_bool), + }) +} diff --git a/packages/api/src/routes/events/invitations.rs b/packages/api/src/routes/events/invitations.rs index 01e0ef4c..b5e0bb2f 100644 --- a/packages/api/src/routes/events/invitations.rs +++ b/packages/api/src/routes/events/invitations.rs @@ -43,7 +43,7 @@ pub async fn send_invite( (event.owner_id == user.id).or_unauthorized("Unauthorized to invite anyone to this event")?; - let user = find_user_by_email(reciever_mail, &ext.database).await?; + let user = find_user_by_email(&reciever_mail, &ext.database).await?; let invite_status = can_invite_user_to_event(&ext.database, event_id, user.id).await?; diff --git a/packages/api/src/routes/mod.rs b/packages/api/src/routes/mod.rs index cb541602..00fe6812 100644 --- a/packages/api/src/routes/mod.rs +++ b/packages/api/src/routes/mod.rs @@ -1,5 +1,7 @@ +pub mod app_config; pub mod events; pub mod groups; +pub mod oidc; pub mod todo_list; pub mod todos; pub mod users; diff --git a/packages/api/src/routes/oidc.rs b/packages/api/src/routes/oidc.rs new file mode 100644 index 00000000..c5a28786 --- /dev/null +++ b/packages/api/src/routes/oidc.rs @@ -0,0 +1,153 @@ +#[cfg(feature = "server")] +use crate::server; +use dioxus::fullstack::Redirect; +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use dioxus::server::axum::Extension; + +const OIDC_SESSION_KEY: &str = "oidc_metadata"; +#[allow(clippy::unused_async)] +#[get("/api/oidc/login", state: Extension, session: Extension )] +pub async fn oauth_login() -> Result { + use crate::server::auth::oidc; + + let oidc_config = state + .oidc_config + .as_ref() + .or_internal_server_error("OAuth Client Missing or disabled!")?; + let metadata = oidc::create_oidc_challenge(&oidc_config.client); + + let redirect_url = metadata.url.as_str().to_string(); + let oidc_session: oidc::OidcSession = metadata.into(); + + session + .insert(OIDC_SESSION_KEY, oidc_session) + .await + .or_internal_server_error("Failed to create session")?; + + Ok(Redirect::to(&redirect_url)) +} + +#[get("/api/oidc/redirect?state&code", + ext: Extension, + cookies: Extension, + session: Extension +)] +pub async fn oauth_redirect(state: String, code: String) -> Result { + use crate::server::auth::oidc; + use crate::server::auth::oidc::add_oidc_cookies; + use entity::prelude::*; + use openidconnect::{AccessTokenHash, OAuth2TokenResponse, TokenResponse}; + use sea_orm::prelude::*; + + let oidc_client = ext + .oidc_config + .as_ref() + .map(|c| &c.client) + .or_internal_server_error("Oidc not initialized / enabled")?; + + let oidc_session: oidc::OidcSession = session + .get(OIDC_SESSION_KEY) + .await + .or_internal_server_error("Failed to retrieve session")? + .or_bad_request("Failed to get session with required metadata")?; + + (*oidc_session.csrf_token.secret() == state).or_bad_request("CSRF Mismatch")?; + + let token_response = + oidc::verify_oidc_challenge(oidc_client, code, oidc_session.pkce_code_verifier) + .await + .inspect_err(|e| error!("{e}")) + .or_forbidden("Error receiving token")?; + + let id_token = token_response + .id_token() + .or_bad_request("Server did not return ID Token")?; + let id_token_verifier = oidc_client.id_token_verifier(); + let claims = id_token + .claims(&id_token_verifier, &oidc_session.nonce) + .or_bad_request("Unable to validate claims")?; + + if let Some(expected_access_token_hash) = claims.access_token_hash() { + let actual_access_token_hash = AccessTokenHash::from_token( + token_response.access_token(), + id_token + .signing_alg() + .or_bad_request("Error extracting sining algorithm")?, + id_token + .signing_key(&id_token_verifier) + .or_bad_request("Error extracting signing key")?, + ) + .or_internal_server_error("Error constructing expected access token")?; + (actual_access_token_hash == *expected_access_token_hash) + .or_unauthorized("Invalid access token")?; + } + + debug!( + "User {} with e-mail address {} has authenticated successfully", + claims.subject().as_str(), + claims + .email() + .map_or("", |email| email.as_str()) + ); + + let email: &str = claims + .email() + .map(|email| email.as_str()) + .or_bad_request("Email is required")?; + let first_name: &str = claims + .given_name() + .or_bad_request("Missing given name")? + .get(None) + .map(|n| n.as_str()) + .or_bad_request("Missing given name")?; + let last_name: &str = claims + .family_name() + .or_bad_request("Missing family name")? + .get(None) + .map(|n| n.as_str()) + .or_bad_request("Missing family name")?; + + if User::find_by_email(email) + .count(&ext.database) + .await + .or_internal_server_error("Failed to retrieve user")? + == 0 + { + let new_user = entity::user::ActiveModel { + email: sea_orm::Set(email.to_string()), + first_name: sea_orm::Set(first_name.to_string()), + last_name: sea_orm::Set(last_name.to_string()), + password: sea_orm::Set(None), + is_oidc_user: sea_orm::Set(true), + ..Default::default() + }; + new_user + .insert(&ext.database) + .await + .inspect_err(|e| error!("{e}")) + .or_internal_server_error("Failed to create user")?; + } + + add_oidc_cookies(&cookies, &token_response) + .or_internal_server_error("Failed to add cookies")?; + + Ok(Redirect::to("/")) +} + +#[post("/api/oidc/refresh", state: Extension, cookies: Extension)] +pub async fn refresh_authorization_token() -> Result<(), ServerFnError> { + use crate::server::auth::oidc::add_oidc_cookies; + let refresh_token_cookie = cookies + .get("refresh_token") + .or_bad_request("Invalid refresh token")?; + let refresh_token = refresh_token_cookie.value(); + + let tokens = server::auth::oidc::refresh_authorization_token(refresh_token, &state) + .await + .or_internal_server_error("Failed to refresh token")?; + + add_oidc_cookies(&cookies, &tokens).or_internal_server_error("Failed to set cookies")?; + Ok(()) +} diff --git a/packages/api/src/routes/users.rs b/packages/api/src/routes/users.rs index 70fbc135..499b1246 100644 --- a/packages/api/src/routes/users.rs +++ b/packages/api/src/routes/users.rs @@ -1,7 +1,6 @@ use crate::dioxus_fullstack::NoContent; #[cfg(feature = "server")] use crate::server; -use dioxus::fullstack::{SetCookie, SetHeader}; use dioxus::prelude::*; #[cfg(feature = "server")] @@ -14,6 +13,7 @@ pub struct UserInfo { pub email: String, pub first_name: String, pub last_name: String, + pub is_oidc_user: bool, } impl UserInfo { @@ -23,6 +23,7 @@ impl UserInfo { email: user.email, first_name: user.first_name, last_name: user.last_name, + is_oidc_user: user.is_oidc_user, } } } @@ -42,7 +43,8 @@ pub async fn retrieve_user(user_id: i32) -> dioxus::Result, auth: Extension)] +#[post("/api/users/signup", ext: Extension, auth: Extension +)] pub async fn sign_up( email: String, password: String, @@ -74,10 +76,12 @@ pub async fn sign_up( } } -#[post("/api/users/login", ext: Extension, auth: Extension)] -pub async fn login(email: String, password: String) -> Result, ServerFnError> { +#[post("/api/users/login", ext: Extension, auth: Extension, cookies: Extension )] +pub async fn login(email: String, password: String) -> Result { use crate::server::auth::{create_session, verify_user}; - use time::format_description::well_known::Rfc2822; + use crate::server::constants; + use tower_cookies::Cookie; + use tower_cookies::cookie::SameSite; if auth.is_authenticated() { return Err(ServerFnError::ServerError { @@ -97,23 +101,27 @@ pub async fn login(email: String, password: String) -> Result, mut auth: Extension)] +#[post("/api/logout", ext: Extension, mut auth: Extension +)] pub async fn logout() -> Result { auth.logout(&ext.database).await?; Ok(NoContent) } -#[delete("/api/users/{user_id}", ext: Extension, auth: Extension)] +#[delete("/api/users/{user_id}", ext: Extension, auth: Extension +)] pub async fn delete_user(user_id: i32) -> Result { use entity::user::Entity as User; use sea_orm::EntityTrait; @@ -136,6 +144,7 @@ pub async fn delete_user(user_id: i32) -> Result { Ok(NoContent) } +#[allow(clippy::unused_async)] #[get("/api/me", auth: Extension)] pub async fn get_me() -> Result { let auth_user = auth.user.clone().or_unauthorized("Not authenticated")?; @@ -164,7 +173,9 @@ pub async fn change_user_info( user_active.first_name = sea_orm::Set(first_name); user_active.last_name = sea_orm::Set(last_name); - user_active.email = sea_orm::Set(email); + if !user.is_oidc_user { + user_active.email = sea_orm::Set(email); + } let res = User::update(user_active) .exec(&ext.database) @@ -174,7 +185,7 @@ pub async fn change_user_info( Ok(UserInfo::from_user_model(res)) } -#[put("/api/users/password", ext: Extension, auth: Extension)] +#[put("/api/users/password", ext: Extension, auth: Extension )] pub async fn change_password(password: String) -> dioxus::Result { use crate::server::auth::hash_password; use entity::user::Entity as User; @@ -183,10 +194,12 @@ pub async fn change_password(password: String) -> dioxus::Result dioxus::Result { use crate::routes::users::UserInfo; @@ -202,7 +209,7 @@ pub async fn find_user_by_email( use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; let user_option = User::find() - .filter(entity::user::Column::Email.eq(&email)) + .filter(entity::user::Column::Email.eq(email)) .one(db) .await .or_internal_server_error("Error loading user from database")?; diff --git a/packages/api/src/server/auth/middleware.rs b/packages/api/src/server/auth/middleware.rs new file mode 100644 index 00000000..823a7f1a --- /dev/null +++ b/packages/api/src/server/auth/middleware.rs @@ -0,0 +1,129 @@ +use crate::server::auth::find_user_by_session; +use crate::server::auth::oidc::{ + add_oidc_cookies, get_user_from_authorization_token, refresh_authorization_token, +}; +use crate::server::{AppState, constants}; +use anyhow::anyhow; +use dioxus::fullstack::axum::middleware::Next; +use dioxus::fullstack::extract::Request; +use dioxus::fullstack::response::Response; +use dioxus::prelude::*; +use entity::prelude::Session; +use openidconnect::OAuth2TokenResponse; +use sea_orm::{DatabaseConnection, EntityTrait}; +use tower_cookies::Cookies; + +#[derive(Clone, Debug)] +pub struct AuthenticationState { + pub user: Option, + session_id: Option, +} + +impl AuthenticationState { + #[must_use] + pub fn is_authenticated(&self) -> bool { + self.user.is_some() + } + + #[must_use] + pub fn is_anonymous(&self) -> bool { + self.user.is_none() + } + + /// Logs the authenticated user out + /// + /// # Errors + /// + /// Returns [`anyhow::Error`] ([`DBError`]) when deleting the session fails + pub async fn logout(&mut self, database: &DatabaseConnection) -> Result<(), anyhow::Error> { + let Some(session_id) = self.session_id else { + return Err(anyhow!("Session ID is None")); + }; + let delete_result = Session::delete_by_id(session_id).exec(database).await?; + if delete_result.rows_affected == 0 { + return Err(anyhow!("Session not found")); + } + self.session_id = None; + self.user = None; + Ok(()) + } +} + +fn extract_bearer(value: &str) -> Option<&str> { + value.strip_prefix("Bearer ") +} + +/// Middleware handling the auth state of a request. +/// Auth gets checked in the following order:\ +/// Header (Authorization Token) -> Cookie (Session Token) -> Cookie (Authorization Token) -> Cookie (Refresh Token) +/// +/// If the authorization using session and authorization token fails, the session gets refreshed using the OIDC provider. +/// +/// # Arguments +/// +/// * `request`: Incoming Request +/// * `next`: Next middleware in the middleware stack +/// +/// returns: Result, `StatusCode`> +/// +/// # Errors +/// * [`StatusCode::INTERNAL_SERVER_ERROR`]: If extracting either cookies or the app state fails +pub async fn authentication_middleware( + mut request: Request, + next: Next, +) -> Result { + let mut authentication_state = AuthenticationState { + user: None, + session_id: None, + }; + + let app_state = request + .extensions() + .get::() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + let database = &app_state.database; + + let cookies = request + .extensions() + .get::() + .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; + + if let Some(token) = request.headers().get("Authorization") + && let Ok(token) = token.to_str() + && let Some(token) = extract_bearer(token) + && let Ok(user) = get_user_from_authorization_token(token, app_state).await + { + authentication_state.user = user; + } else if let Some(cookie) = cookies.get(constants::SESSION_COOKIE_NAME) + && let Ok(Some((user, session_id))) = find_user_by_session(cookie.value(), database).await + { + authentication_state.user = Some(user); + authentication_state.session_id = Some(session_id); + } else if let Some(cookie) = cookies.get(constants::OIDC_AUTHORIZATION_COOKIE_NAME) + && let Some(token) = extract_bearer(cookie.value()) + && let Ok(user) = get_user_from_authorization_token(token, app_state).await + { + authentication_state.user = user; + } else if let Some(cookie) = cookies.get(constants::OIDC_REFRESH_COOKIE_NAME) + && let Some(refresh_token) = extract_bearer(cookie.value()) + { + match refresh_authorization_token(refresh_token, app_state).await { + Ok(token_response) => { + if let Err(err) = add_oidc_cookies(cookies, &token_response) { + error!("Error adding OAuth Cookies: {err}"); + } + let access_token = token_response.access_token().secret(); + if let Ok(user) = get_user_from_authorization_token(access_token, app_state).await { + authentication_state.user = user; + } + } + Err(err) => { + warn!("Refresh token error: {err}"); + } + } + } + + request.extensions_mut().insert(authentication_state); + + Ok(next.run(request).await) +} diff --git a/packages/api/src/server/auth/oidc.rs b/packages/api/src/server/auth/oidc.rs new file mode 100644 index 00000000..44ee617c --- /dev/null +++ b/packages/api/src/server/auth/oidc.rs @@ -0,0 +1,334 @@ +use crate::server; +use crate::server::utils::get_env_or; +use dioxus::prelude::*; +use entity::prelude::User; +use jsonwebtoken::jwk::JwkSet; +use jsonwebtoken::{DecodingKey, Validation}; +use openidconnect::core::{ + CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType, + CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata, + CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, + CoreTokenResponse, +}; +use openidconnect::url::Url; +use openidconnect::{ + AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, + EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, JsonWebKeySetUrl, Nonce, + OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, + StandardErrorResponse, reqwest, +}; +use serde::{Deserialize, Serialize}; +use server::{AppState, constants}; +use std::env; +use std::sync::Arc; +use std::time::Duration; +use time::ext::NumericalDuration; +use tokio::sync::RwLock; +use tower_cookies::cookie::SameSite; +use tower_cookies::{Cookie, Cookies}; + +pub type OidcClient = Client< + EmptyAdditionalClaims, + CoreAuthDisplay, + CoreGenderClaim, + CoreJweContentEncryptionAlgorithm, + CoreJsonWebKey, + CoreAuthPrompt, + StandardErrorResponse, + CoreTokenResponse, + CoreTokenIntrospectionResponse, + CoreRevocableToken, + CoreRevocationErrorResponse, + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; + +#[derive(Clone)] +pub struct JwksState { + pub jwks: Arc>, + pub jwks_uri: JsonWebKeySetUrl, +} + +#[derive(Clone)] +pub struct OidcConfig { + pub client: OidcClient, + pub metadata: CoreProviderMetadata, + pub jwks_state: JwksState, +} + +impl OidcConfig { + #[must_use] + pub fn new( + client: OidcClient, + metadata: CoreProviderMetadata, + jwks: JwkSet, + jwks_uri: JsonWebKeySetUrl, + ) -> Self { + Self { + client, + metadata, + jwks_state: JwksState { + jwks: Arc::new(RwLock::new(jwks)), + jwks_uri, + }, + } + } +} + +impl From for OidcClient { + fn from(value: OidcConfig) -> Self { + value.client + } +} + +pub(crate) fn build_http_client() -> Result { + let http_client = reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + Ok(http_client) +} + +pub(crate) async fn fetch_jwks(jwks_uri: &JsonWebKeySetUrl) -> Result { + let http_client = build_http_client()?; + let jwks = http_client + .get(jwks_uri.url().as_str()) + .send() + .await? + .json::() + .await?; + debug!("JWKS: {:?}", jwks); + Ok(jwks) +} + +pub(crate) async fn jwks_refresh_loop(state: JwksState, refresh_duration: time::Duration) { + let mut interval = + tokio::time::interval(refresh_duration.try_into().expect("invalid duration")); + loop { + interval.tick().await; + + match fetch_jwks(&state.jwks_uri).await { + Ok(new_jwks) => { + info!("JWKS updated with {} keys", new_jwks.keys.len()); + let mut jwks = state.jwks.write().await; + *jwks = new_jwks; + } + Err(err) => { + warn!("JWKS refresh failed: {err}"); + } + } + } +} + +pub(crate) async fn create_oidc_config() -> Result { + let http_client = build_http_client()?; + + let provider_metadata = CoreProviderMetadata::discover_async( + IssuerUrl::new(env::var(constants::OIDC_ISSUER_URL_ENV_VAR)?)?, + &http_client, + ) + .await?; + let jwks_uri = provider_metadata.jwks_uri().clone(); + + let redirect_url = format!( + "{}/api/oidc/redirect", + env::var(constants::SERVER_URL_ENV_VAR)? + ); + let client = CoreClient::from_provider_metadata( + provider_metadata.clone(), + ClientId::new(env::var(constants::OIDC_CLIENT_ID_ENV_VAR)?), + Some(ClientSecret::new(env::var( + constants::OIDC_CLIENT_SECRET_ENV_VAR, + )?)), + ) + .set_redirect_uri(RedirectUrl::new(redirect_url)?); + + let jwks = fetch_jwks(&jwks_uri).await?; + + Ok(OidcConfig::new(client, provider_metadata, jwks, jwks_uri)) +} + +pub struct OidcMetadata { + pub url: Url, + pub csrf_token: CsrfToken, + pub pkce_code_verifier: PkceCodeVerifier, + pub nonce: Nonce, +} + +#[derive(Serialize, Deserialize)] +pub struct OidcSession { + pub(crate) pkce_code_verifier: PkceCodeVerifier, + pub(crate) csrf_token: CsrfToken, + pub(crate) nonce: Nonce, +} + +impl From for OidcSession { + fn from(value: OidcMetadata) -> Self { + Self { + pkce_code_verifier: value.pkce_code_verifier, + nonce: value.nonce, + csrf_token: value.csrf_token, + } + } +} + +pub(crate) fn create_oidc_challenge(client: &OidcClient) -> OidcMetadata { + let (pkce_challenge, pkce_code_verifier) = PkceCodeChallenge::new_random_sha256(); + let mut authorization_request = client.authorize_url( + CoreAuthenticationFlow::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ); + + let env_scopes = + env::var(constants::OIDC_SCOPES_ENV_VAR).unwrap_or("openid email profile".to_string()); + let scopes = env_scopes.split_whitespace(); + + for scope in scopes { + authorization_request = authorization_request.add_scope(Scope::new(scope.to_string())); + } + authorization_request = authorization_request.set_pkce_challenge(pkce_challenge); + + let (auth_url, csrf_token, nonce) = authorization_request.url(); + + OidcMetadata { + url: auth_url, + pkce_code_verifier, + nonce, + csrf_token, + } +} + +/// Verifies a pkce challenge and returns the `IdToken` +/// +/// # Arguments +/// +/// * `client`: +/// * `authorization_code`: +/// * `pkce_code_verifier`: +/// +/// returns: Result<`CoreTokenResponse`, Error> +/// +/// # Errors +/// +/// * `ConfigurationError`: `exchange_code` failed to to uninitialized `OidcClient` +/// * `RequestTokenError`: Fetching the token from the `AuthProvider` failed +/// * Errors retuned by [`build_http_client`] +pub(crate) async fn verify_oidc_challenge( + client: &OidcClient, + authorization_code: String, + pkce_code_verifier: PkceCodeVerifier, +) -> Result { + let http_client = build_http_client()?; + let token = client + .exchange_code(AuthorizationCode::new(authorization_code))? + .set_pkce_verifier(pkce_code_verifier) + .request_async(&http_client) + .await?; + Ok(token) +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: String, + pub iss: String, + #[serde(default)] + pub aud: Option, + pub exp: usize, + pub email: String, +} + +pub(crate) async fn validate_authorization_token( + oidc_config: &OidcConfig, + token: &str, +) -> Result { + let header = jsonwebtoken::decode_header(token)?; + let kid = header.kid.ok_or(anyhow::anyhow!("Missing kid"))?; + let jwk_lock = oidc_config.jwks_state.jwks.read().await; + let jwk = jwk_lock.find(&kid).ok_or(anyhow::anyhow!("Missing jwk"))?; + + let mut validation = Validation::new(header.alg); + validation.set_issuer(&[oidc_config.metadata.issuer()]); + let audiences = get_env_or( + constants::OIDC_AUDIENCE_ENV_VAR, + vec![oidc_config.client.client_id().as_str().to_string()], + |val| val.trim().split(',').map(ToString::to_string).collect(), + ); + validation.set_audience(&audiences); + + let token_data = jsonwebtoken::decode(token, &DecodingKey::from_jwk(jwk)?, &validation)?; + + Ok(token_data.claims) +} + +pub(crate) async fn get_user_from_authorization_token( + token: &str, + app_state: &AppState, +) -> Result, anyhow::Error> { + let oidc_config = app_state.oidc_config.as_ref().expect("OIDC is disabled!"); + + let claims = validate_authorization_token(oidc_config, token) + .await + .inspect_err(|e| error!("Error validating OIDC claims: {}", e))?; + let user = User::find_by_email(claims.email) + .one(&app_state.database) + .await?; + Ok(user) +} + +pub(crate) async fn refresh_authorization_token( + refresh_token: &str, + app_state: &AppState, +) -> Result { + let oidc_client = &app_state + .oidc_config + .as_ref() + .expect("OIDC is disabled!") + .client; + let http_client = build_http_client()?; + + let refresh_token = RefreshToken::new(refresh_token.to_owned()); + let refresh_token_request = oidc_client.exchange_refresh_token(&refresh_token)?; + let new_token = refresh_token_request.request_async(&http_client).await?; + Ok(new_token) +} + +pub(crate) fn add_oidc_cookies( + cookies: &Cookies, + token_response: &CoreTokenResponse, +) -> Result<(), anyhow::Error> { + let expires_at = token_response + .expires_in() + .unwrap_or(Duration::from_secs(3600)); + + cookies.add( + Cookie::build(( + constants::OIDC_AUTHORIZATION_COOKIE_NAME, + format!("Bearer {}", token_response.access_token().secret()), + )) + .secure(!cfg!(debug_assertions)) + .http_only(true) + .path("/") + .same_site(SameSite::Strict) + .expires(time::OffsetDateTime::now_local()? + expires_at) + .build(), + ); + if let Some(refresh_token) = token_response.refresh_token() { + cookies.add( + Cookie::build(( + constants::OIDC_REFRESH_COOKIE_NAME, + format!("Bearer {}", refresh_token.secret()), + )) + .secure(!cfg!(debug_assertions)) + .http_only(true) + .path("/") + .same_site(SameSite::Strict) + .expires(time::OffsetDateTime::now_local()? + 30.days()) + .build(), + ); + } + Ok(()) +} diff --git a/packages/api/src/server/constants.rs b/packages/api/src/server/constants.rs new file mode 100644 index 00000000..b79bff52 --- /dev/null +++ b/packages/api/src/server/constants.rs @@ -0,0 +1,15 @@ +pub const OIDC_ENABLED_ENV_VAR: &str = "OIDC_ENABLED"; +pub const OIDC_PROVIDER_NAME_ENV_VAR: &str = "OIDC_PROVIDER_NAME"; +pub const OIDC_ISSUER_URL_ENV_VAR: &str = "OIDC_ISSUER_URL"; +pub const OIDC_CLIENT_ID_ENV_VAR: &str = "OIDC_CLIENT_ID"; +pub const OIDC_CLIENT_SECRET_ENV_VAR: &str = "OIDC_CLIENT_SECRET"; +pub const OIDC_SCOPES_ENV_VAR: &str = "OIDC_SCOPES"; +pub const OIDC_JWKS_REFRESH_INTERVAL_ENV_VAR: &str = "OIDC_JWKS_REFRESH_INTERVAL"; +pub const OIDC_AUDIENCE_ENV_VAR: &str = "OIDC_AUDIENCE"; +pub const SERVER_URL_ENV_VAR: &str = "SERVER_URL"; +pub const SIGNUP_ENABLED_ENV_VAR: &str = "SIGNUP_ENABLED"; + +pub const OIDC_AUTHORIZATION_COOKIE_NAME: &str = "authorization"; +pub const OIDC_REFRESH_COOKIE_NAME: &str = "refresh_token"; + +pub const SESSION_COOKIE_NAME: &str = "session"; diff --git a/packages/api/src/server/middleware/mod.rs b/packages/api/src/server/middleware/mod.rs new file mode 100644 index 00000000..14ee2dd6 --- /dev/null +++ b/packages/api/src/server/middleware/mod.rs @@ -0,0 +1,2 @@ +mod tracing_middleware; +pub use tracing_middleware::tracing_middleware; diff --git a/packages/api/src/server/middleware/tracing_middleware.rs b/packages/api/src/server/middleware/tracing_middleware.rs new file mode 100644 index 00000000..37f3710f --- /dev/null +++ b/packages/api/src/server/middleware/tracing_middleware.rs @@ -0,0 +1,39 @@ +use crate::server::auth::AuthenticationState; +use dioxus::fullstack::axum::middleware::Next; +use dioxus::fullstack::extract::Request; +use dioxus::fullstack::response::Response; +use dioxus::prelude::*; +use std::env; + +pub async fn tracing_middleware(request: Request, next: Next) -> Response { + let method = request.method().clone(); + let path = request.uri().path().to_string(); + let username = request.extensions().get::().map_or( + "Anonymous".to_string(), + |state| { + state + .user + .as_ref() + .map(|user| user.email.clone()) + .unwrap_or("Anonymous".to_string()) + }, + ); + + let response = next.run(request).await; + + if !env::var("ACCESS_LOG").is_ok_and(|value| value.to_lowercase() == "true") { + return response; + } + + let base_message = format!("{method} {path} {} - {username}", response.status()); + + if response.status().is_server_error() { + error!("{}", base_message); + } else if response.status().is_client_error() { + warn!("{}", base_message); + } else { + info!("{}", base_message); + } + + response +} diff --git a/packages/api/src/server/mod.rs b/packages/api/src/server/mod.rs index 7be78b10..bb8740af 100644 --- a/packages/api/src/server/mod.rs +++ b/packages/api/src/server/mod.rs @@ -1,6 +1,10 @@ pub mod setup; -pub use setup::{AppState, AuthenticationState, setup_api}; +pub use setup::{AppState, setup_api}; pub mod auth; +pub use auth::AuthenticationState; +pub(crate) mod constants; mod database; pub mod events; +pub mod middleware; pub(crate) mod todo_lists; +pub mod utils; diff --git a/packages/api/src/server/setup.rs b/packages/api/src/server/setup.rs index e7fe220c..d029b7ea 100644 --- a/packages/api/src/server/setup.rs +++ b/packages/api/src/server/setup.rs @@ -1,17 +1,17 @@ -use super::*; -use crate::server::auth::find_user_by_session; -use anyhow::anyhow; +use super::{constants, database}; +use crate::server::auth::middleware::authentication_middleware; +use crate::server::auth::oidc::{OidcConfig, create_oidc_config, jwks_refresh_loop}; +use crate::server::middleware::tracing_middleware; +use crate::server::utils::{convert_env_to_bool, get_env_or, parse_env_string}; use dioxus::core::Element; -use dioxus::fullstack::Cookie; -use dioxus::fullstack::axum::middleware::Next; -use dioxus::fullstack::extract::Request; -use dioxus::fullstack::headers::HeaderMapExt; -use dioxus::fullstack::response::Response; use dioxus::prelude::*; use dioxus::server::axum; use dioxus::server::axum::Extension; -use entity::prelude::Session; -use sea_orm::{DatabaseConnection, EntityTrait}; +use sea_orm::DatabaseConnection; +use time::Duration; +use time::ext::NumericalDuration; +use tower_cookies::CookieManagerLayer; +use tower_sessions::{Expiry, MemoryStore, SessionManagerLayer}; pub async fn setup_api(app: fn() -> Element) -> Result { let database: DatabaseConnection = database::establish_connection().await?; @@ -22,20 +22,40 @@ pub async fn setup_api(app: fn() -> Element) -> Result Element) -> Result Response { - let mut authentication_state = AuthenticationState { - user: None, - session_id: None, - }; - - if let Some(token) = request.headers().get("Authorization") - && let Ok(token) = token.to_str() - && let Some(token) = token.strip_prefix("Token ") - && let Ok(Some((user, session_id))) = find_user_by_session(token, &database).await - { - authentication_state.user = Some(user); - authentication_state.session_id = Some(session_id); - } else if let Some(cookies) = request.headers().typed_get::() - && let Some(token) = cookies.get("session") - && let Ok(Some((user, session_id))) = find_user_by_session(token, &database).await - { - authentication_state.user = Some(user); - authentication_state.session_id = Some(session_id); - } - - request.extensions_mut().insert(authentication_state); - - next.run(request).await -} - -async fn tracing_middleware(request: Request, next: Next) -> Response { - let method = request.method().clone(); - let path = request.uri().path().to_string(); - let username = request.extensions().get::().map_or( - "Anonymous".to_string(), - |state| { - state - .user - .as_ref() - .map(|user| user.email.clone()) - .unwrap_or("Anonymous".to_string()) - }, - ); - - let response = next.run(request).await; - - if !std::env::var("ACCESS_LOG").is_ok_and(|value| value.to_lowercase() == "true") { - return response; - } - - let base_message = format!("{method} {path} {} - {username}", response.status()); - - if response.status().is_server_error() { - error!("{}", base_message); - } else if response.status().is_client_error() { - warn!("{}", base_message); - } else { - info!("{}", base_message); - } - - response -} - -#[derive(Clone, Debug)] -pub struct AuthenticationState { - pub user: Option, - session_id: Option, -} - -impl AuthenticationState { - pub fn is_authenticated(&self) -> bool { - self.user.is_some() - } - - pub fn is_anonymous(&self) -> bool { - self.user.is_none() - } - - pub async fn logout(&mut self, database: &DatabaseConnection) -> Result<(), anyhow::Error> { - let Some(session_id) = self.session_id else { - return Err(anyhow!("Session ID is None")); - }; - let delete_result = Session::delete_by_id(session_id).exec(database).await?; - if delete_result.rows_affected == 0 { - return Err(anyhow!("Session not found")); - } - self.session_id = None; - self.user = None; - Ok(()) - } + pub oidc_config: Option, } diff --git a/packages/api/src/server/todo_lists.rs b/packages/api/src/server/todo_lists.rs index 070a09b4..e6151d5f 100644 --- a/packages/api/src/server/todo_lists.rs +++ b/packages/api/src/server/todo_lists.rs @@ -96,7 +96,8 @@ mod tests { email: sea_orm::Set(format!("test{id}@test.de")), first_name: sea_orm::Set(format!("firstname{id}")), last_name: sea_orm::Set(format!("firstname{id}")), - password: sea_orm::Set("test".to_string()), + password: sea_orm::Set(Some("test".to_string())), + is_oidc_user: sea_orm::Set(false), } .insert(database) .await diff --git a/packages/api/src/server/utils.rs b/packages/api/src/server/utils.rs new file mode 100644 index 00000000..a66e92d8 --- /dev/null +++ b/packages/api/src/server/utils.rs @@ -0,0 +1,21 @@ +use std::env; + +pub fn convert_env_to_bool(value: &str) -> bool { + match value.to_lowercase().trim() { + "" | "true" | "yes" | "1" => true, + "false" | "no" | "0" | &_ => false, + } +} + +pub fn parse_env_string(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + None + } else { + Some(value.to_owned()) + } +} + +pub fn get_env_or T>(env_key: &str, default: T, conversion: F) -> T { + env::var(env_key).map_or(default, |v| conversion(&v)) +} diff --git a/packages/api/tests/db_basic_tests.rs b/packages/api/tests/db_basic_tests.rs index 9fe6929f..b1c26def 100644 --- a/packages/api/tests/db_basic_tests.rs +++ b/packages/api/tests/db_basic_tests.rs @@ -46,7 +46,8 @@ mod test_db_mod { email: Set("proton".to_owned()), first_name: Set("kara".to_owned()), last_name: Set("rau".to_owned()), - password: Set("pass".to_owned()), + password: Set(Some("pass".to_owned())), + is_oidc_user: Set(false), }; user::Entity::insert(user1).exec(db).await?; @@ -56,7 +57,8 @@ mod test_db_mod { email: Set("gmail".to_owned()), first_name: Set("simon".to_owned()), last_name: Set("hhh".to_owned()), - password: Set("word".to_owned()), + password: Set(Some("word".to_owned())), + is_oidc_user: Set(false), }; user::Entity::insert(user2).exec(db).await?; diff --git a/packages/entity/src/user.rs b/packages/entity/src/user.rs index 11a2d126..b0eab9f1 100644 --- a/packages/entity/src/user.rs +++ b/packages/entity/src/user.rs @@ -12,7 +12,9 @@ pub struct Model { pub first_name: String, pub last_name: String, #[serde(skip)] - pub password: String, + pub password: Option, + + pub is_oidc_user: bool, //events that belong to this user #[sea_orm(has_many)] diff --git a/packages/frontend/src/components/contexts/app_config_provider.rs b/packages/frontend/src/components/contexts/app_config_provider.rs new file mode 100644 index 00000000..6b57d68e --- /dev/null +++ b/packages/frontend/src/components/contexts/app_config_provider.rs @@ -0,0 +1,30 @@ +use api::routes::app_config::{AppConfig, get_app_config}; +use dioxus::prelude::*; + +#[derive(Clone, PartialEq, Eq)] +pub struct AppConfigContext { + inner: AppConfig, +} + +impl AppConfigContext { + pub fn new(inner: AppConfig) -> Self { + Self { inner } + } +} + +#[component] +pub fn AppConfigProvider(children: Element) -> Element { + let app_config = use_loader(get_app_config)?; + + use_context_provider(|| AppConfigContext::new(app_config.cloned())); + + rsx! { + {children} + } +} + +pub fn use_app_config() -> AppConfig { + try_use_context::() + .expect("use_app_settings can only be used inside AppConfigContext") + .inner +} diff --git a/packages/frontend/src/components/contexts/mod.rs b/packages/frontend/src/components/contexts/mod.rs index 4b72b1aa..fc471428 100644 --- a/packages/frontend/src/components/contexts/mod.rs +++ b/packages/frontend/src/components/contexts/mod.rs @@ -1,2 +1,5 @@ +mod app_config_provider; mod auth_provider; + +pub use app_config_provider::{AppConfigProvider, use_app_config}; pub use auth_provider::{AuthGuard, AuthProvider, AuthState, use_auth}; diff --git a/packages/frontend/src/components/ui/navbar.rs b/packages/frontend/src/components/ui/navbar.rs index e7b7fe2d..e0c2b435 100644 --- a/packages/frontend/src/components/ui/navbar.rs +++ b/packages/frontend/src/components/ui/navbar.rs @@ -1,5 +1,5 @@ use crate::Route; -use crate::components::contexts::use_auth; +use crate::components::contexts::{use_app_config, use_auth}; use crate::components::ui::sidebar::SidebarState; use crate::components::ui::theme_controller::ThemeController; use api::routes::users::logout; @@ -11,6 +11,7 @@ use dioxus_free_icons::icons::ld_icons::LdMenu; pub fn Navbar() -> Element { let mut sidebar_state = use_context::(); let mut auth_state = use_auth(); + let app_config = use_app_config(); let nav = navigator(); rsx! { @@ -62,10 +63,12 @@ pub fn Navbar() -> Element { } } } else { - Link { - to: Route::SignupView {}, - class: "btn btn-secondary btn-sm mx-1", - "Sign Up" + if app_config.signup_enabled { + Link { + to: Route::SignupView {}, + class: "btn btn-secondary btn-sm mx-1", + "Sign Up" + } } Link { to: Route::LoginPage {}, diff --git a/packages/frontend/src/layouts/standard_app.rs b/packages/frontend/src/layouts/standard_app.rs index 0251e2b7..426d532a 100644 --- a/packages/frontend/src/layouts/standard_app.rs +++ b/packages/frontend/src/layouts/standard_app.rs @@ -1,5 +1,5 @@ use crate::components::ErrorDisplay; -use crate::components::contexts::AuthProvider; +use crate::components::contexts::{AppConfigProvider, AuthProvider}; use crate::components::ui::dock::Dock; use crate::components::ui::loader::Loader; use crate::components::ui::toaster::ToastProvider; @@ -13,80 +13,82 @@ use dioxus_free_icons::icons::ld_icons::{LdCircleHelp, LdCircleX}; #[component] pub fn StandardAppLayout(children: Element) -> Element { rsx! { - AuthProvider { - SidebarProvider { - div { class: "min-h-screen flex flex-col", - input { - id: "drawer-toggle", - r#type: "checkbox", - class: "drawer-toggle", - } - Navbar {} - ToastProvider { - main { class: "grow mx-10 mt-5 mb-20 lg:mb-5", - ErrorBoundary { - handle_error: |error: ErrorContext| { - let http_error = FullstackContext::commit_error_status(error.error().unwrap()); - let error_component = match http_error.status { - StatusCode::NOT_FOUND => rsx! { - ErrorDisplay { - title: "Page Not Found", - description: "The page you are looking for does not exist.", - action_text: "Return to Home", - icon: LdCircleHelp, - redirect_route: Route::Home {}, - error_context: Some(error), - } - }, - StatusCode::UNAUTHORIZED => rsx! { - ErrorDisplay:: { - title: "Access Denied", - description: "You must be logged in to access this page.", - action_text: "Go to Login", - redirect_route: Route::LoginPage {}, - error_context: Some(error), - } - }, - StatusCode::FORBIDDEN => rsx! { - ErrorDisplay:: { - title: "Access Denied", - description: "You do not have permission to access this page.", - action_text: "Go to Home", - redirect_route: Route::Home {}, - error_context: Some(error), - } - }, - _ => rsx! { - ErrorDisplay { - title: "An unknown error occurred", - description: "Something went wrong while loading the page. Please try again later.", - action_text: "Return to Home", - icon: LdCircleX, - redirect_route: Route::Home {}, - error_context: Some(error), - } - }, - }; - rsx! { - {error_component} - } - }, - SuspenseBoundary { - fallback: |_| { + AppConfigProvider { + AuthProvider { + SidebarProvider { + div { class: "min-h-screen flex flex-col", + input { + id: "drawer-toggle", + r#type: "checkbox", + class: "drawer-toggle", + } + Navbar {} + ToastProvider { + main { class: "grow mx-10 mt-5 mb-20 lg:mb-5", + ErrorBoundary { + handle_error: |error: ErrorContext| { + let http_error = FullstackContext::commit_error_status(error.error().unwrap()); + let error_component = match http_error.status { + StatusCode::NOT_FOUND => rsx! { + ErrorDisplay { + title: "Page Not Found", + description: "The page you are looking for does not exist.", + action_text: "Return to Home", + icon: LdCircleHelp, + redirect_route: Route::Home {}, + error_context: Some(error), + } + }, + StatusCode::UNAUTHORIZED => rsx! { + ErrorDisplay:: { + title: "Access Denied", + description: "You must be logged in to access this page.", + action_text: "Go to Login", + redirect_route: Route::LoginPage {}, + error_context: Some(error), + } + }, + StatusCode::FORBIDDEN => rsx! { + ErrorDisplay:: { + title: "Access Denied", + description: "You do not have permission to access this page.", + action_text: "Go to Home", + redirect_route: Route::Home {}, + error_context: Some(error), + } + }, + _ => rsx! { + ErrorDisplay { + title: "An unknown error occurred", + description: "Something went wrong while loading the page. Please try again later.", + action_text: "Return to Home", + icon: LdCircleX, + redirect_route: Route::Home {}, + error_context: Some(error), + } + }, + }; rsx! { - div { class: "flex items-center justify-center gap-2", - Loader {} - "RoomMates is loading..." - } + {error_component} } }, - Outlet:: {} + SuspenseBoundary { + fallback: |_| { + rsx! { + div { class: "flex items-center justify-center gap-2", + Loader {} + "RoomMates is loading..." + } + } + }, + Outlet:: {} + } } } } } + Dock {} } - Dock {} } } } diff --git a/packages/frontend/src/views/home.rs b/packages/frontend/src/views/home.rs index 37e54fff..438b2c01 100644 --- a/packages/frontend/src/views/home.rs +++ b/packages/frontend/src/views/home.rs @@ -37,9 +37,9 @@ fn Dashboard() -> Element { is_loading_events.set(true); }; - // Sadly Loader.loading() is not reactive, so we have to keep a seperate loading state + // Sadly Loader.loading() is not reactive, so we have to keep a separate loading state use_effect(move || { - // events.read() is required to subscribe to state chamges. When events changes, it has finished loading new data + // events.read() is required to subscribe to state changes. When events changes, it has finished loading new data events.read(); is_loading_events.set(false); }); diff --git a/packages/frontend/src/views/login.rs b/packages/frontend/src/views/login.rs index ad8c1691..0a53430d 100644 --- a/packages/frontend/src/views/login.rs +++ b/packages/frontend/src/views/login.rs @@ -1,4 +1,4 @@ -use crate::components::contexts::AuthState; +use crate::components::contexts::{use_app_config, use_auth}; use crate::components::ui::card::{Card, CardActions, CardBody, CardTitle}; use crate::components::ui::form::input::Input; use crate::components::ui::form::submit_button::SubmitButton; @@ -21,11 +21,13 @@ struct LoginFormData { #[component] pub fn LoginPage() -> Element { + let app_config = use_app_config(); + let mut login_action = use_action(login); let mut get_me = use_action(get_me); let mut form_errors = use_signal(Vec::::new); - let mut auth_state = use_context::(); + let mut auth_state = use_auth(); // If already logged in, redirect to home if auth_state.user.read().is_some() { @@ -71,9 +73,9 @@ pub fn LoginPage() -> Element { debug!("Failed to log in with error {:?}", error); } None => { - debug!("No value present!") + debug!("No value present!"); } - }; + } }); rsx! { @@ -124,12 +126,25 @@ pub fn LoginPage() -> Element { label: "Login", submitting_label: "Logging in...", } - p { - "Dont have an account? " - Link { - to: Route::SignupView {}, - class: "link", - "Sign Up" + if app_config.oidc_enabled { + a { + href: "/api/oidc/login", + class: "btn btn-primary grow w-full", + if let Some(name) = app_config.oidc_provider_name { + "Login with {name}" + } else { + "Login using SSO" + } + } + } + if !app_config.signup_enabled { + p { + "Dont have an account? " + Link { + to: Route::SignupView {}, + class: "link", + "Sign Up" + } } } } diff --git a/packages/frontend/src/views/profile.rs b/packages/frontend/src/views/profile.rs index db5a919e..9d5d025b 100644 --- a/packages/frontend/src/views/profile.rs +++ b/packages/frontend/src/views/profile.rs @@ -56,7 +56,9 @@ pub fn Profile() -> Element { CardTitle { "Profile Information" } ListInfoDisplay { real_user, onupdate } - PasswordDisplay {} + if !user().is_oidc_user { + PasswordDisplay {} + } } } } @@ -103,7 +105,6 @@ pub fn ListInfoDisplay(real_user: UserInfo, onupdate: EventHandler) -> rsx! { form { onsubmit, Card { - " Username: {real_user.first_name} {real_user.last_name}" Fieldset { div { fieldset { class: "fieldset", @@ -123,7 +124,12 @@ pub fn ListInfoDisplay(real_user: UserInfo, onupdate: EventHandler) -> Fieldset { p { "Email: {real_user.email}" } - Input { label: "Set Email", field: email, r#type: "email" } + Input { + label: "Set Email", + field: email, + r#type: "email", + disabled: real_user.is_oidc_user, + } } CardActions { SubmitButton { form: form_state.clone(), label: "Confirm New Info" } diff --git a/packages/frontend/src/views/sign_up.rs b/packages/frontend/src/views/sign_up.rs index 63e7ad61..b5d8a1c7 100644 --- a/packages/frontend/src/views/sign_up.rs +++ b/packages/frontend/src/views/sign_up.rs @@ -1,6 +1,6 @@ use std::rc::Rc; -use crate::components::contexts::AuthState; +use crate::components::contexts::{AuthState, use_app_config}; use crate::components::ui::card::{Card, CardActions, CardBody, CardTitle}; use crate::components::ui::form::input::Input; use crate::components::ui::form::submit_button::SubmitButton; @@ -24,7 +24,9 @@ struct SignupFormData { #[component] pub fn SignupView() -> Element { - let nav = navigator(); + let app_config = use_app_config(); + let nav = use_navigator(); + let mut form_errors = use_signal(Vec::::new); let mut sign_up_action = use_action(move |form_data: SignupFormData| async move { sign_up( @@ -40,8 +42,9 @@ pub fn SignupView() -> Element { // If already logged in, redirect to home if auth_state.user.read().is_some() { - let nav = navigator(); nav.push(Route::Home {}); + } else if !app_config.signup_enabled { + nav.replace(Route::LoginPage {}); } let mut form_state = use_form(); From d4118d4d8d117b64b4e45b6546915e5299cde64f Mon Sep 17 00:00:00 2001 From: Simon Felix Seeger <50206768+SFSeeger@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:46:47 +0100 Subject: [PATCH 2/3] docs: Update Readme --- README.md | 113 +++++++++++++----- .../screenshots}/calendar.png | Bin .../screenshots}/dashboard-dark.png | Bin .../screenshots/dashboard-pastel.png | Bin .../screenshots}/eventcreate.png | Bin .../screenshots}/group-dark.png | Bin .../screenshots}/todolist.png | Bin 7 files changed, 82 insertions(+), 31 deletions(-) rename {screenshots => docs/screenshots}/calendar.png (100%) rename {screenshots => docs/screenshots}/dashboard-dark.png (100%) rename screenshots/dashboard pastel.png => docs/screenshots/dashboard-pastel.png (100%) rename {screenshots => docs/screenshots}/eventcreate.png (100%) rename {screenshots => docs/screenshots}/group-dark.png (100%) rename {screenshots => docs/screenshots}/todolist.png (100%) diff --git a/README.md b/README.md index a4824021..bf93f378 100644 --- a/README.md +++ b/README.md @@ -11,34 +11,38 @@ - [Features](#features) - [Deployment](#deployment) - - [Server](#server) - - [Server with Sqlite Database](#server-with-sqlite-database) - - [Server with MySQL/MariaDB](#server-with-mysqlmariadb) - - [Clients](#clients) - - [Android](#android) - - [Tools and Dependencies](#tools-and-dependencies) + - [Server](#server) + - [Server with Sqlite Database](#server-with-sqlite-database) + - [Server with MySQL/MariaDB](#server-with-mysqlmariadb) + - [OIDC Support](#oidc-support) + - [Clients](#clients) + - [Android](#android) + - [Tools and Dependencies](#tools-and-dependencies) - [Development](#development) - - [Project Structure](#project-structure) - - [Serving Your App](#serving-your-app) - - [Development Services](#development-services) - - [Dev Container](#dev-container) - - [Pre-Commit Hooks](#pre-commit-hooks) - - [Testing](#testing) + - [Project Structure](#project-structure) + - [Serving Your App](#serving-your-app) + - [Development Services](#development-services) + - [Dev Container](#dev-container) + - [Pre-Commit Hooks](#pre-commit-hooks) + - [Testing](#testing) - [Disclosure of AI Usage](#disclosure-of-ai-usage) ## Features + * Create, view and share Events with groups and individual users -![alt text](screenshots/eventcreate.png)![alt text](screenshots/group-dark.png) ![alt text](screenshots/calendar.png) + ![Event Creation](docs/screenshots/eventcreate.png) + ![Group List](docs/screenshots/group-dark.png) + ![Calendar](docs/screenshots/calendar.png) * Manage and collaborate on To-Do Lists with different permissions -![alt text](screenshots/todolist.png) + ![Todo List](docs/screenshots/todolist.png) * Choose from multiple themes to personalize your experience -![alt text](screenshots/dashboard-dark.png) -![alt text]() + ![Dashboard in a dark color theme](docs/screenshots/dashboard-dark.png) + ![Dashboard in a pastel color theme](docs/screenshots/dashboard-pastel.png) * Compatible with both MySQL and SQLite databases * Cross-Platform Support ([See Clients](#clients)) - +* OIDC Support ## Deployment @@ -90,19 +94,41 @@ volumes: ``` And a `.env` file like this in the same directory: + ```shell MYSQL_PASSWORD = MYSQL_ROOT_PASSWORD = ``` + Then run: ````shell docker compose --env-file .env up -d ```` +#### OIDC Support + +RoomMates supports OIDC using the [openidconnect crate](https://docs.rs/openidconnect/latest/openidconnect/). +OIDC is not enabled by default and can be configured using the following Environment Variables: + +| Enviroment Variable Name | Description | Required | +|:-----------------------------:|------------------------------------------------------------------------------------------------------------------------------------------------------------------|:------------------:| +| `OIDC_ENABLED` | Controls wether OIDC is enabled or not | :white_check_mark: | +| `OIDC_ISSUER_URL` | Url to the Issuer. Gets used to retrieve the required Metadata using the .well-known endpoint | :white_check_mark: | +| `OIDC_CLIENT_ID` | Client ID used to authenticate against | :white_check_mark: | +| `OIDC_CLIENT_SECRET` | Client Secret for the supplied client | :white_check_mark: | +| `SERVER_URL` | Domain where the server is deployed. Used for redirection after the OIDC flow is compleated | :white_check_mark: | +| `SIGNUP_ENABLED` | Controls wether users can create an account using the signup provided by RoomMates. OIDC accounds will always be created | :x: | +| `OIDC_SCOPES` | Scopes the application has access to. Note that the application needs at least the email as well as the given and family name Defaults to `email profile openid` | :x: | +| `OIDC_PROVIDE_NAME` | The name of the provider displayed in the login form. Defauls to SSO | :x: | +| `OIDC_JWKS_REFRESH_INTERVALL` | Intervall in secounds when to reload the jwks used to validate auth tokens. | :x: | +| `OIDC_AUDIENCE` | Comma seperated list of audiences (e.g. `account,app`) | :x: | + ### Clients -Bundling the following targets have been tested. While bundling untested targets may work, there is a chance they require additional configuration. +Bundling the following targets have been tested. While bundling untested targets may work, there is a chance they +require additional configuration. + - [X] Web - [X] Linux - [ ] Windows @@ -110,7 +136,6 @@ Bundling the following targets have been tested. While bundling untested targets - [X] Android - [ ] iOS - To bundle clients for production, install the [required tools](#tools-and-dependencies) or use the devcontainer. Then choose the platform you want to bundle and optionally the [package type](https://dioxuslabs.com/learn/0.7/tutorial/bundle#bundling-for-desktop-and-mobile). @@ -119,25 +144,27 @@ Run the following command in the root of the project[^1]: > `SERVER_URL` should be the URL of your deployed server. Defaults to `http://localhost:8080`. > [!TIP] -> You can also bundle the server this way, if you don't want to use docker. In this case set `PLATFORM` to web. You can omit `SERVER_URL` as it is not needed for the web platform. +> You can also bundle the server this way, if you don't want to use docker. In this case set `PLATFORM` to web. You can +> omit `SERVER_URL` as it is not needed for the web platform. ```shell make bundle PLATFORM= SERVER_URL="" [PACKAGES=" [ ...]"] ``` + #### Android + > [!IMPORTANT] -> This bundeling config assumes you have a valid keystore in `~/.android/keystore.jks`. You can override the keystore location by using the `KEYSTORE_PATH` argument when bundling. +> This bundling config assumes you have a valid keystore in `~/.android/keystore.jks`. You can override the keystore +> location by using the `KEYSTORE_PATH` argument when bundling. > Refer to [the android docs](https://developer.android.com/studio/publish/app-signing) on how to create one > [!NOTE] > This creates a `.apk` file for sideloading. The `.aab` bundle created by dioxus does not include the app icon - ```shell make bundle PLATFORM=android SERVER_URL="" KEYSTORE_PASSWORD="" ``` - ### Tools and Dependencies For bundling, refer to @@ -203,11 +230,28 @@ make dev-server PLATFORM=desktop ### Development Services -| Port | Service | Description | -| ---- | ----------- | ------------------------------------------------ | -| 8080 | Application | The Application served by the development server | -| 8000 | phpMyAdmin | Database frontend for development | -| 3306 | MariaDB | Database Server | +The RoomMates Dev Container uses a `traefik` to route to all required services. For this to work, you need to extend +your `/etc/hosts` (Linux / MacOS) `%windir%\system32\drivers\etc` (Windows) with these lines: + +```text +127.0.0.1 roommates.local +127.0.0.1 auth.roommates.local +127.0.0.1 db.roommates.local +127.0.0.1 traefik.roommates.local +``` + +| Domain | Service | Description | +|:-------------------------:|:--------------|-------------------------------------------------------| +| `roommates.local` | Dev Container | Forwards port 8080 | +| `auth.roommates.local` | Keycloak | Auth Provider for OIDC | +| `db.roommates.local` | PhpMyAdmin | Database Frontend | +| `traefik.roommates.local` | Traefik | Used to access `traefik` dashboard under `/dashboard` | + +If you want to use OIDC in the development, you also need to create a User in the Keycloak. For this, open +`auth.roommates.local` and login using the username `admin` with password `password`. +Then click `Manage Realms > RoomMates`. Once you are in the RoomMates Realm navigate to `Users > Create new User`. +After entering an email, first name and last name, open the tab `Credentials` and set a password. Make sure to disable +`temporary`, otherwise you'll need to change it on first login. ### Dev Container @@ -226,7 +270,12 @@ should already be installed in the devcontainer. Test Disclaimer: -Some tests for basic and advanced database logic were done using unit tests. However later on, most tests were conducted by directly running and using the project, since a lot of the work was concerning the UI. Additionally most of the database operations are pretty similar, so in the interest of saving time, there was not a need to write individual tests for every one of them. The focus was on working directly with the interactive components of the project, seeing what worked and gaining concrete information about occurring errors through example data and debugging with developer tools. +Some tests for basic and advanced database logic were done using unit tests. However later on, most tests were conducted +by directly running and using the project, since a lot of the work was concerning the UI. Additionally most of the +database operations are pretty similar, so in the interest of saving time, there was not a need to write individual +tests for every one of them. The focus was on working directly with the interactive components of the project, seeing +what worked and gaining concrete information about occurring errors through example data and debugging with developer +tools. To run the tests for the project, use the following command: @@ -235,11 +284,13 @@ make tests ``` ## Disclosure of AI Usage -AI was used for Tab-Completing and Debugging, never for generating whole sections of code without a human creating derivatives of said generated code. + +AI was used for Tab-Completing and Debugging, never for generating whole sections of code without a human creating +derivatives of said generated code. Model used were: + - ChatGPT 4o, 4.1 and 5 - Github Copilot - Google Gemini - [^1]: Angle brackets (`<>`) indicate required arguments, square brackets (`[]`) indicate optional arguments. diff --git a/screenshots/calendar.png b/docs/screenshots/calendar.png similarity index 100% rename from screenshots/calendar.png rename to docs/screenshots/calendar.png diff --git a/screenshots/dashboard-dark.png b/docs/screenshots/dashboard-dark.png similarity index 100% rename from screenshots/dashboard-dark.png rename to docs/screenshots/dashboard-dark.png diff --git a/screenshots/dashboard pastel.png b/docs/screenshots/dashboard-pastel.png similarity index 100% rename from screenshots/dashboard pastel.png rename to docs/screenshots/dashboard-pastel.png diff --git a/screenshots/eventcreate.png b/docs/screenshots/eventcreate.png similarity index 100% rename from screenshots/eventcreate.png rename to docs/screenshots/eventcreate.png diff --git a/screenshots/group-dark.png b/docs/screenshots/group-dark.png similarity index 100% rename from screenshots/group-dark.png rename to docs/screenshots/group-dark.png diff --git a/screenshots/todolist.png b/docs/screenshots/todolist.png similarity index 100% rename from screenshots/todolist.png rename to docs/screenshots/todolist.png From c0926df4c00d42e1948053b4ef3272b87c31f475 Mon Sep 17 00:00:00 2001 From: Simon Felix Seeger <50206768+SFSeeger@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:58:35 +0100 Subject: [PATCH 3/3] feat: Automatic image build & push --- .github/workflows/build-container.yml | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/build-container.yml diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml new file mode 100644 index 00000000..9f648285 --- /dev/null +++ b/.github/workflows/build-container.yml @@ -0,0 +1,42 @@ +name: build-container.yml +on: + push: + tags: + - '*.*.*' +jobs: + build: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Extract tag name + id: meta + run: | + TAG=${GITHUB_REF#refs/tags/} + echo "tag=$TAG" >> $GITHUB_OUTPUT + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set image name + id: image + run: | + IMAGE=ghcr.io/${{ github.repository }} + echo "image=$IMAGE" >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v7 + with: + push: true + tags: ${{ steps.image.outputs.image }}:latest, ${{ steps.image.outputs.image }}:${{ steps.meta.outputs.tag }}