diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index a379124f..e07f2b1f 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -48,8 +48,12 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService) svc.customClaimService = service.NewCustomClaimService(db) + svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService) + if err != nil { + return nil, fmt.Errorf("failed to create WebAuthn service: %w", err) + } - svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService) + svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService) if err != nil { return nil, fmt.Errorf("failed to create OIDC service: %w", err) } @@ -58,10 +62,5 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService) svc.apiKeyService = service.NewApiKeyService(db, svc.emailService) - svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService) - if err != nil { - return nil, fmt.Errorf("failed to create WebAuthn service: %w", err) - } - return svc, nil } diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 403dc87d..c570bd32 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -350,6 +350,15 @@ func (e *OidcAuthorizationPendingError) HttpStatusCode() int { return http.StatusBadRequest } +type ReauthenticationRequiredError struct{} + +func (e *ReauthenticationRequiredError) Error() string { + return "reauthentication required" +} +func (e *ReauthenticationRequiredError) HttpStatusCode() int { + return http.StatusUnauthorized +} + type OpenSignupDisabledError struct{} func (e *OpenSignupDisabledError) Error() string { diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go index f9b9051a..51ffb587 100644 --- a/backend/internal/controller/webauthn_controller.go +++ b/backend/internal/controller/webauthn_controller.go @@ -25,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler) + group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler) + group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler) group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler) group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler) @@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) { cookie.AddAccessTokenCookie(c, 0, "") c.Status(http.StatusNoContent) } + +func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) { + sessionID, err := c.Cookie(cookie.SessionIdCookieName) + if err != nil { + _ = c.Error(&common.MissingSessionIdError{}) + return + } + + var token string + + // Try to create a reauthentication token with WebAuthn + credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) + if err == nil { + token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData) + if err != nil { + _ = c.Error(err) + return + } + } else { + // If WebAuthn fails, try to create a reauthentication token with the access token + accessToken, _ := c.Cookie(cookie.AccessTokenCookieName) + token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken) + if err != nil { + _ = c.Error(err) + return + } + } + + c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token}) +} diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index fdd907a7..c76b495d 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -3,10 +3,11 @@ package dto import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" type OidcClientMetaDataDto struct { - ID string `json:"id"` - Name string `json:"name"` - HasLogo bool `json:"hasLogo"` - LaunchURL *string `json:"launchURL"` + ID string `json:"id"` + Name string `json:"name"` + HasLogo bool `json:"hasLogo"` + LaunchURL *string `json:"launchURL"` + RequiresReauthentication bool `json:"requiresReauthentication"` } type OidcClientDto struct { @@ -29,13 +30,14 @@ type OidcClientWithAllowedGroupsCountDto struct { } type OidcClientCreateDto struct { - Name string `json:"name" binding:"required,max=50" unorm:"nfc"` - CallbackURLs []string `json:"callbackURLs"` - LogoutCallbackURLs []string `json:"logoutCallbackURLs"` - IsPublic bool `json:"isPublic"` - PkceEnabled bool `json:"pkceEnabled"` - Credentials OidcClientCredentialsDto `json:"credentials"` - LaunchURL *string `json:"launchURL" binding:"omitempty,url"` + Name string `json:"name" binding:"required,max=50" unorm:"nfc"` + CallbackURLs []string `json:"callbackURLs"` + LogoutCallbackURLs []string `json:"logoutCallbackURLs"` + IsPublic bool `json:"isPublic"` + PkceEnabled bool `json:"pkceEnabled"` + RequiresReauthentication bool `json:"requiresReauthentication"` + Credentials OidcClientCredentialsDto `json:"credentials"` + LaunchURL *string `json:"launchURL" binding:"omitempty,url"` } type OidcClientCredentialsDto struct { @@ -50,12 +52,13 @@ type OidcClientFederatedIdentityDto struct { } type AuthorizeOidcClientRequestDto struct { - ClientID string `json:"clientID" binding:"required"` - Scope string `json:"scope" binding:"required"` - CallbackURL string `json:"callbackURL"` - Nonce string `json:"nonce"` - CodeChallenge string `json:"codeChallenge"` - CodeChallengeMethod string `json:"codeChallengeMethod"` + ClientID string `json:"clientID" binding:"required"` + Scope string `json:"scope" binding:"required"` + CallbackURL string `json:"callbackURL"` + Nonce string `json:"nonce"` + CodeChallenge string `json:"codeChallenge"` + CodeChallengeMethod string `json:"codeChallengeMethod"` + ReauthenticationToken string `json:"reauthenticationToken"` } type AuthorizeOidcClientResponseDto struct { diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 5bbc61c8..3e89e886 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -25,6 +25,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true), s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true), s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true), + s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true), s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true), ) } @@ -104,6 +105,20 @@ func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error { return nil } +// ClearReauthenticationTokens deletes reauthentication tokens that have expired +func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error { + st := j.db. + WithContext(ctx). + Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now())) + if st.Error != nil { + return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error) + } + + slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected)) + + return nil +} + // ClearAuditLogs deletes audit logs older than 90 days func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { st := j.db. diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go index 0b9f825c..36923ec4 100644 --- a/backend/internal/model/oidc.go +++ b/backend/internal/model/oidc.go @@ -40,16 +40,17 @@ type OidcAuthorizationCode struct { type OidcClient struct { Base - Name string `sortable:"true"` - Secret string - CallbackURLs UrlList - LogoutCallbackURLs UrlList - ImageType *string - HasLogo bool `gorm:"-"` - IsPublic bool - PkceEnabled bool - Credentials OidcClientCredentials - LaunchURL *string + Name string `sortable:"true"` + Secret string + CallbackURLs UrlList + LogoutCallbackURLs UrlList + ImageType *string + HasLogo bool `gorm:"-"` + IsPublic bool + PkceEnabled bool + RequiresReauthentication bool + Credentials OidcClientCredentials + LaunchURL *string AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` CreatedByID string diff --git a/backend/internal/model/webauthn.go b/backend/internal/model/webauthn.go index bbe2ddf3..d8d4d008 100644 --- a/backend/internal/model/webauthn.go +++ b/backend/internal/model/webauthn.go @@ -45,6 +45,15 @@ type PublicKeyCredentialRequestOptions struct { Timeout time.Duration } +type ReauthenticationToken struct { + Base + Token string + ExpiresAt datatype.DateTime + + UserID string + User User +} + type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck // Scan and Value methods for GORM to handle the custom type diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index ceebd113..2dfd28c5 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -50,6 +50,7 @@ type OidcService struct { appConfigService *AppConfigService auditLogService *AuditLogService customClaimService *CustomClaimService + webAuthnService *WebAuthnService httpClient *http.Client jwkCache *jwk.Cache @@ -62,6 +63,7 @@ func NewOidcService( appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService, + webAuthnService *WebAuthnService, ) (s *OidcService, err error) { s = &OidcService{ db: db, @@ -69,6 +71,7 @@ func NewOidcService( appConfigService: appConfigService, auditLogService: auditLogService, customClaimService: customClaimService, + webAuthnService: webAuthnService, } // Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace @@ -123,6 +126,16 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie return "", "", err } + if client.RequiresReauthentication { + if input.ReauthenticationToken == "" { + return "", "", &common.ReauthenticationRequiredError{} + } + err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID) + if err != nil { + return "", "", err + } + } + // If the client is not public, the code challenge must be provided if client.IsPublic && input.CodeChallenge == "" { return "", "", &common.OidcMissingCodeChallengeError{} @@ -714,6 +727,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien client.IsPublic = input.IsPublic // PKCE is required for public clients client.PkceEnabled = input.IsPublic || input.PkceEnabled + client.RequiresReauthentication = input.RequiresReauthentication client.LaunchURL = input.LaunchURL // Credentials diff --git a/backend/internal/service/webauthn_service.go b/backend/internal/service/webauthn_service.go index f87652c6..de244537 100644 --- a/backend/internal/service/webauthn_service.go +++ b/backend/internal/service/webauthn_service.go @@ -336,3 +336,136 @@ func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credenti func (s *WebAuthnService) updateWebAuthnConfig() { s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value } + +func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + token, err := s.jwtService.VerifyAccessToken(accessToken) + if err != nil { + return "", fmt.Errorf("invalid access token: %w", err) + } + + userID, ok := token.Subject() + if !ok { + return "", fmt.Errorf("access token does not contain user ID") + } + + // Check if token is issued less than a minute ago + tokenExpiration, ok := token.IssuedAt() + if !ok || time.Since(tokenExpiration) > time.Minute { + return "", &common.ReauthenticationRequiredError{} + } + + var user model.User + err = tx. + WithContext(ctx). + First(&user, "id = ?", userID). + Error + if err != nil { + return "", fmt.Errorf("failed to load user: %w", err) + } + + reauthToken, err := s.createReauthenticationToken(ctx, tx, user.ID) + if err != nil { + return "", err + } + + err = tx.Commit().Error + if err != nil { + return "", err + } + + return reauthToken, nil +} + +func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (string, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + // Retrieve and delete the session + var storedSession model.WebauthnSession + err := tx. + WithContext(ctx). + Clauses(clause.Returning{}). + Delete(&storedSession, "id = ? AND expires_at > ?", sessionID, datatype.DateTime(time.Now())). + Error + if err != nil { + return "", fmt.Errorf("failed to load WebAuthn session: %w", err) + } + + session := webauthn.SessionData{ + Challenge: storedSession.Challenge, + Expires: storedSession.ExpiresAt.ToTime(), + } + + // Validate the credential assertion + var user *model.User + _, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) { + innerErr := tx. + WithContext(ctx). + Preload("Credentials"). + First(&user, "id = ?", string(userHandle)). + Error + if innerErr != nil { + return nil, innerErr + } + return user, nil + }, session, credentialAssertionData) + + if err != nil || user == nil { + return "", err + } + + // Create reauthentication token + token, err := s.createReauthenticationToken(ctx, tx, user.ID) + if err != nil { + return "", err + } + + err = tx.Commit().Error + if err != nil { + return "", err + } + + return token, nil +} + +func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) error { + hashedToken := utils.CreateSha256Hash(token) + result := tx.WithContext(ctx). + Clauses(clause.Returning{}). + Delete(&model.ReauthenticationToken{}, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now())) + + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return &common.ReauthenticationRequiredError{} + } + return nil +} + +func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) { + token, err := utils.GenerateRandomAlphanumericString(32) + if err != nil { + return "", err + } + + reauthToken := model.ReauthenticationToken{ + Token: utils.CreateSha256Hash(token), + ExpiresAt: datatype.DateTime(time.Now().Add(3 * time.Minute)), + UserID: userID, + } + + err = tx.WithContext(ctx).Create(&reauthToken).Error + if err != nil { + return "", err + } + + return token, nil +} diff --git a/backend/resources/migrations/postgres/20250814121300_requires_reauthentication.down.sql b/backend/resources/migrations/postgres/20250814121300_requires_reauthentication.down.sql new file mode 100644 index 00000000..5873b6f7 --- /dev/null +++ b/backend/resources/migrations/postgres/20250814121300_requires_reauthentication.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication; +DROP TABLE IF EXISTS reauthentication_tokens; diff --git a/backend/resources/migrations/postgres/20250814121300_requires_reauthentication.up.sql b/backend/resources/migrations/postgres/20250814121300_requires_reauthentication.up.sql new file mode 100644 index 00000000..32cdf3d4 --- /dev/null +++ b/backend/resources/migrations/postgres/20250814121300_requires_reauthentication.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE reauthentication_tokens ( + id TEXT PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE +); + +CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token); \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250814121300_requires_reauthentication.down.sql b/backend/resources/migrations/sqlite/20250814121300_requires_reauthentication.down.sql new file mode 100644 index 00000000..2b669840 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250814121300_requires_reauthentication.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication; +DROP INDEX IF EXISTS idx_reauthentication_tokens_token; +DROP TABLE IF EXISTS reauthentication_tokens; \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20250814121300_requires_reauthentication.up.sql b/backend/resources/migrations/sqlite/20250814121300_requires_reauthentication.up.sql new file mode 100644 index 00000000..e5691bc7 --- /dev/null +++ b/backend/resources/migrations/sqlite/20250814121300_requires_reauthentication.up.sql @@ -0,0 +1,11 @@ +ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE reauthentication_tokens ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE +); + +CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token); \ No newline at end of file diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 9a3d626c..dfa9ddc3 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -276,6 +276,8 @@ "public_clients_description": "Öffentliche Clients haben kein Client-Geheimnis und verwenden stattdessen PKCE. Aktiviere dies, wenn dein Client eine SPA oder mobile App ist.", "pkce": "PKCE", "public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Der Public Key Code Exchange (öffentlicher Schlüsselaustausch) ist eine Sicherheitsfunktion, um CSRF Angriffe und Angriffe zum Abfangen von Autorisierungscodes zu verhindern.", + "requires_reauthentication": "Erfordert erneute Authentifizierung", + "requires_users_to_authenticate_again_on_each_authorization": "Erfordert eine neue Authentifizierung bei jeder Autorisierung, auch wenn der Benutzer bereits angemeldet ist", "name_logo": "{name} Logo", "change_logo": "Logo ändern", "upload_logo": "Logo hochladen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index ad385218..5c74c327 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -276,6 +276,8 @@ "public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.", "pkce": "PKCE", "public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.", + "requires_reauthentication": "Requires Re-Authentication", + "requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in", "name_logo": "{name} logo", "change_logo": "Change Logo", "upload_logo": "Upload Logo", diff --git a/frontend/src/lib/services/oidc-service.ts b/frontend/src/lib/services/oidc-service.ts index c7d5b798..1d0a1572 100644 --- a/frontend/src/lib/services/oidc-service.ts +++ b/frontend/src/lib/services/oidc-service.ts @@ -19,7 +19,8 @@ class OidcService extends APIService { callbackURL: string, nonce?: string, codeChallenge?: string, - codeChallengeMethod?: string + codeChallengeMethod?: string, + reauthenticationToken?: string ) { const res = await this.api.post('/oidc/authorize', { scope, @@ -27,7 +28,8 @@ class OidcService extends APIService { callbackURL, clientId, codeChallenge, - codeChallengeMethod + codeChallengeMethod, + reauthenticationToken }); return res.data as AuthorizeResponse; diff --git a/frontend/src/lib/services/webauthn-service.ts b/frontend/src/lib/services/webauthn-service.ts index 0a267e7b..080efa26 100644 --- a/frontend/src/lib/services/webauthn-service.ts +++ b/frontend/src/lib/services/webauthn-service.ts @@ -37,6 +37,11 @@ class WebAuthnService extends APIService { async updateCredentialName(id: string, name: string) { await this.api.patch(`/webauthn/credentials/${id}`, { name }); } + + async reauthenticate(body?: AuthenticationResponseJSON) { + const res = await this.api.post('/webauthn/reauthenticate', body); + return res.data.reauthenticationToken as string; + } } export default WebAuthnService; diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts index bad54330..470796cd 100644 --- a/frontend/src/lib/types/oidc.type.ts +++ b/frontend/src/lib/types/oidc.type.ts @@ -4,6 +4,7 @@ export type OidcClientMetaData = { id: string; name: string; hasLogo: boolean; + requiresReauthentication: boolean; launchURL?: string; }; @@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & { logoutCallbackURLs: string[]; isPublic: boolean; pkceEnabled: boolean; + requiresReauthentication: boolean; credentials?: OidcClientCredentials; launchURL?: string; }; diff --git a/frontend/src/routes/authorize/+page.svelte b/frontend/src/routes/authorize/+page.svelte index 37787768..a70d68ef 100644 --- a/frontend/src/routes/authorize/+page.svelte +++ b/frontend/src/routes/authorize/+page.svelte @@ -11,7 +11,7 @@ import userStore from '$lib/stores/user-store'; import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte'; - import { startAuthentication } from '@simplewebauthn/browser'; + import { startAuthentication, type AuthenticationResponseJSON } from '@simplewebauthn/browser'; import { onMount } from 'svelte'; import { slide } from 'svelte/transition'; import type { PageProps } from './$types'; @@ -29,6 +29,7 @@ let errorMessage: string | null = $state(null); let authorizationRequired = $state(false); let authorizationConfirmed = $state(false); + let userSignedInAt: Date | undefined; onMount(() => { if ($userStore) { @@ -38,13 +39,16 @@ async function authorize() { isLoading = true; + + let authResponse: AuthenticationResponseJSON | undefined; + try { - // Get access token if not signed in if (!$userStore?.id) { const loginOptions = await webauthnService.getLoginOptions(); - const authResponse = await startAuthentication({ optionsJSON: loginOptions }); + authResponse = await startAuthentication({ optionsJSON: loginOptions }); const user = await webauthnService.finishLogin(authResponse); - await userStore.setUser(user); + userStore.setUser(user); + userSignedInAt = new Date(); } if (!authorizationConfirmed) { @@ -56,8 +60,28 @@ } } + let reauthToken: string | undefined; + if (client?.requiresReauthentication) { + let authResponse; + const signedInRecently = + userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000; + if (!signedInRecently) { + const loginOptions = await webauthnService.getLoginOptions(); + authResponse = await startAuthentication({ optionsJSON: loginOptions }); + } + reauthToken = await webauthnService.reauthenticate(authResponse); + } + await oidService - .authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod) + .authorize( + client!.id, + scope, + callbackURL, + nonce, + codeChallenge, + codeChallengeMethod, + reauthToken + ) .then(async ({ code, callbackURL, issuer }) => { onSuccess(code, callbackURL, issuer); }); diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte index 92167233..085c8666 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -36,7 +36,8 @@ [m.userinfo_url()]: `https://${page.url.hostname}/api/oidc/userinfo`, [m.logout_url()]: `https://${page.url.hostname}/api/oidc/end-session`, [m.certificate_url()]: `https://${page.url.hostname}/.well-known/jwks.json`, - [m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled() + [m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(), + [m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled() }); async function updateClient(updatedClient: OidcClientCreateWithLogo) { @@ -49,6 +50,9 @@ client.isPublic = updatedClient.isPublic; setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled(); + setupDetails[m.requires_reauthentication()] = updatedClient.requiresReauthentication + ? m.enabled() + : m.disabled(); await Promise.all([dataPromise, imagePromise]) .then(() => { @@ -120,14 +124,14 @@
- + {client.id}
{#if !client.isPublic}
- + {#if $clientSecretStore} @@ -154,7 +158,7 @@
{#each Object.entries(setupDetails) as [key, value]}
- + {value} diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte index d345af5c..a8a67b69 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte @@ -39,6 +39,7 @@ logoutCallbackURLs: existingClient?.logoutCallbackURLs || [], isPublic: existingClient?.isPublic || false, pkceEnabled: existingClient?.pkceEnabled || false, + requiresReauthentication: existingClient?.requiresReauthentication || false, launchURL: existingClient?.launchURL || '', credentials: { federatedIdentities: existingClient?.credentials?.federatedIdentities || [] @@ -51,6 +52,7 @@ logoutCallbackURLs: z.array(z.string().nonempty()), isPublic: z.boolean(), pkceEnabled: z.boolean(), + requiresReauthentication: z.boolean(), launchURL: optionalUrl, credentials: z.object({ federatedIdentities: z.array( @@ -147,6 +149,12 @@ description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()} bind:checked={$inputs.pkceEnabled.value} /> +
diff --git a/tests/specs/oidc.spec.ts b/tests/specs/oidc.spec.ts index af985d6d..3eb7b3ae 100644 --- a/tests/specs/oidc.spec.ts +++ b/tests/specs/oidc.spec.ts @@ -594,3 +594,30 @@ test('Authorize existing client with federated identity', async ({ page }) => { expect(res.expires_in).not.toBeNull; expect(res.token_type).toBe('Bearer'); }); + +test('Forces reauthentication when client requires it', async ({ page, request }) => { + let webauthnStartCalled = false; + await page.route('/api/webauthn/login/start', async (route) => { + webauthnStartCalled = true; + await route.continue(); + }); + + await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, { + data: { ...oidcClients.nextcloud, requiresReauthentication: true } + }); + + await (await passkeyUtil.init(page)).addPasskey(); + + const urlParams = createUrlParams(oidcClients.nextcloud); + await page.goto(`/authorize?${urlParams.toString()}`); + + await expect(page.getByTestId('scopes')).not.toBeVisible(); + + await page.waitForURL(oidcClients.nextcloud.callbackUrl).catch((e) => { + if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) { + throw e; + } + }); + + expect(webauthnStartCalled).toBe(true); +});