mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 17:23:24 +03:00
feat: add option to OIDC client to require re-authentication (#747)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type OidcClientMetaDataDto struct {
|
||||
Name string `json:"name"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
LaunchURL *string `json:"launchURL"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
}
|
||||
|
||||
type OidcClientDto struct {
|
||||
@@ -34,6 +35,7 @@ type OidcClientCreateDto struct {
|
||||
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"`
|
||||
}
|
||||
@@ -56,6 +58,7 @@ type AuthorizeOidcClientRequestDto struct {
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -48,6 +48,7 @@ type OidcClient struct {
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
RequiresReauthentication bool
|
||||
Credentials OidcClientCredentials
|
||||
LaunchURL *string
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
|
||||
DROP TABLE IF EXISTS reauthentication_tokens;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 @@
|
||||
<Card.Content>
|
||||
<div class="flex flex-col">
|
||||
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
|
||||
<Label class="mb-0 w-44">{m.client_id()}</Label>
|
||||
<Label class="mb-0 w-50">{m.client_id()}</Label>
|
||||
<CopyToClipboard value={client.id}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{#if !client.isPublic}
|
||||
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
|
||||
<Label class="mb-0 w-44">{m.client_secret()}</Label>
|
||||
<Label class="mb-0 w-50">{m.client_secret()}</Label>
|
||||
{#if $clientSecretStore}
|
||||
<CopyToClipboard value={$clientSecretStore}>
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||
@@ -154,7 +158,7 @@
|
||||
<div transition:slide>
|
||||
{#each Object.entries(setupDetails) as [key, value]}
|
||||
<div class="mb-5 flex flex-col sm:flex-row sm:items-center">
|
||||
<Label class="mb-0 w-44">{key}</Label>
|
||||
<Label class="mb-0 w-50">{key}</Label>
|
||||
<CopyToClipboard {value}>
|
||||
<span class="text-muted-foreground text-sm">{value}</span>
|
||||
</CopyToClipboard>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<SwitchWithLabel
|
||||
id="requires-reauthentication"
|
||||
label={m.requires_reauthentication()}
|
||||
description={m.requires_users_to_authenticate_again_on_each_authorization()}
|
||||
bind:checked={$inputs.requiresReauthentication.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<Label for="logo">{m.logo()}</Label>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user