mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-05-14 00:49:52 +00:00
feat: add OpenID Connect prompt Parameter Handling (#1299)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -448,3 +448,27 @@ func (e *InvalidEmailVerificationTokenError) Error() string {
|
||||
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
// OIDC prompt parameter errors - used for redirect error responses
|
||||
|
||||
type OidcLoginRequiredError struct{}
|
||||
|
||||
func (e *OidcLoginRequiredError) Error() string { return "login_required" }
|
||||
func (e *OidcLoginRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcConsentRequiredError struct{}
|
||||
|
||||
func (e *OidcConsentRequiredError) Error() string { return "consent_required" }
|
||||
func (e *OidcConsentRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcInteractionRequiredError struct{}
|
||||
|
||||
func (e *OidcInteractionRequiredError) Error() string { return "interaction_required" }
|
||||
func (e *OidcInteractionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcAccountSelectionRequiredError struct{}
|
||||
|
||||
func (e *OidcAccountSelectionRequiredError) Error() string {
|
||||
return "account_selection_required"
|
||||
}
|
||||
func (e *OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -104,6 +104,14 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
c.Request.UserAgent(),
|
||||
)
|
||||
if err != nil {
|
||||
// Check if this is a prompt-related error that should be returned as a redirect error
|
||||
if isOidcPromptError(err) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"error": err.Error(),
|
||||
"requiresRedirect": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -117,6 +125,19 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// isOidcPromptError checks if an error is a prompt-related OIDC error that should trigger a redirect
|
||||
func isOidcPromptError(err error) bool {
|
||||
var loginReq *common.OidcLoginRequiredError
|
||||
var consentReq *common.OidcConsentRequiredError
|
||||
var interactionReq *common.OidcInteractionRequiredError
|
||||
var accountSelectionReq *common.OidcAccountSelectionRequiredError
|
||||
|
||||
return errors.As(err, &loginReq) ||
|
||||
errors.As(err, &consentReq) ||
|
||||
errors.As(err, &interactionReq) ||
|
||||
errors.As(err, &accountSelectionReq)
|
||||
}
|
||||
|
||||
// authorizationConfirmationRequiredHandler godoc
|
||||
// @Summary Check if authorization confirmation is required
|
||||
// @Description Check if the user needs to confirm authorization for the client
|
||||
|
||||
@@ -91,6 +91,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||
"authorization_response_iss_parameter_supported": true,
|
||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||
"prompt_values_supported": []string{"none", "login", "consent"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
}
|
||||
return json.Marshal(config)
|
||||
|
||||
@@ -71,6 +71,7 @@ type AuthorizeOidcClientRequestDto struct {
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
|
||||
@@ -137,27 +137,49 @@ 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{}
|
||||
}
|
||||
|
||||
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
|
||||
// Validate the callback URL before any prompt checks, so that prompt-related
|
||||
// error responses never contain an unvalidated redirect target
|
||||
callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Parse prompt parameter (space-delimited list per OIDC spec)
|
||||
promptValues := parsePromptParameter(input.Prompt)
|
||||
hasPromptNone := contains(promptValues, "none")
|
||||
hasPromptLogin := contains(promptValues, "login")
|
||||
hasPromptConsent := contains(promptValues, "consent")
|
||||
hasPromptSelectAccount := contains(promptValues, "select_account")
|
||||
|
||||
// Validate prompt parameter conflicts early.
|
||||
// Per OIDC Core §3.1.2.6, prompt=none must not be combined with any
|
||||
// value that requires user interaction.
|
||||
if hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount) {
|
||||
return "", "", &common.OidcInteractionRequiredError{}
|
||||
}
|
||||
|
||||
// Handle prompt=select_account early (not supported)
|
||||
if hasPromptSelectAccount {
|
||||
return "", "", &common.OidcInteractionRequiredError{}
|
||||
}
|
||||
|
||||
// If prompt=login is specified or the client requires reauthentication, check the reauthentication token
|
||||
if hasPromptLogin || client.RequiresReauthentication {
|
||||
if input.ReauthenticationToken == "" {
|
||||
return "", "", &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
|
||||
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the user group is allowed to authorize the client
|
||||
var user model.User
|
||||
err = tx.
|
||||
@@ -173,6 +195,17 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
return "", "", &common.OidcAccessDeniedError{}
|
||||
}
|
||||
|
||||
// Handle prompt=none - if consent would be required, we can't show UI
|
||||
if hasPromptNone {
|
||||
hasAlreadyAuthorized, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !hasAlreadyAuthorized {
|
||||
return "", "", &common.OidcConsentRequiredError{}
|
||||
}
|
||||
}
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -2107,3 +2140,21 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// parsePromptParameter parses the OIDC prompt parameter which is a space-delimited list of values
|
||||
func parsePromptParameter(prompt string) []string {
|
||||
if prompt == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Fields(prompt)
|
||||
}
|
||||
|
||||
// contains checks if a string slice contains a specific value
|
||||
func contains(slice []string, value string) bool {
|
||||
for _, item := range slice {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1058,3 +1058,73 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
require.ErrorContains(t, err, "failed to look up client")
|
||||
})
|
||||
}
|
||||
|
||||
// Tests for prompt parameter parsing and handling
|
||||
func TestParsePromptParameter(t *testing.T) {
|
||||
t.Run("empty prompt returns empty slice", func(t *testing.T) {
|
||||
result := parsePromptParameter("")
|
||||
assert.Equal(t, []string{}, result)
|
||||
})
|
||||
|
||||
t.Run("single prompt value", func(t *testing.T) {
|
||||
result := parsePromptParameter("none")
|
||||
assert.Equal(t, []string{"none"}, result)
|
||||
})
|
||||
|
||||
t.Run("multiple prompt values space-delimited", func(t *testing.T) {
|
||||
result := parsePromptParameter("login consent")
|
||||
assert.Equal(t, []string{"login", "consent"}, result)
|
||||
})
|
||||
|
||||
t.Run("multiple prompt values with extra spaces", func(t *testing.T) {
|
||||
result := parsePromptParameter(" none login ")
|
||||
assert.Equal(t, []string{"none", "login"}, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPromptParameterConflicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt string
|
||||
expectConflict bool
|
||||
}{
|
||||
{"none alone is valid", "none", false},
|
||||
{"login alone is valid", "login", false},
|
||||
{"consent alone is valid", "consent", false},
|
||||
{"login consent is valid", "login consent", false},
|
||||
{"none consent conflicts", "none consent", true},
|
||||
{"none login conflicts", "none login", true},
|
||||
{"none select_account conflicts", "none select_account", true},
|
||||
{"none consent login conflicts", "none consent login", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
values := parsePromptParameter(tt.prompt)
|
||||
hasNone := contains(values, "none")
|
||||
hasConsent := contains(values, "consent")
|
||||
hasLogin := contains(values, "login")
|
||||
hasSelectAccount := contains(values, "select_account")
|
||||
|
||||
conflict := hasNone && (hasConsent || hasLogin || hasSelectAccount)
|
||||
assert.Equal(t, tt.expectConflict, conflict)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
t.Run("finds value in slice", func(t *testing.T) {
|
||||
slice := []string{"none", "login", "consent"}
|
||||
assert.True(t, contains(slice, "login"))
|
||||
})
|
||||
|
||||
t.Run("returns false for missing value", func(t *testing.T) {
|
||||
slice := []string{"none", "login"}
|
||||
assert.False(t, contains(slice, "consent"))
|
||||
})
|
||||
|
||||
t.Run("returns false for empty slice", func(t *testing.T) {
|
||||
slice := []string{}
|
||||
assert.False(t, contains(slice, "none"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ class OidcService extends APIService {
|
||||
nonce?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string,
|
||||
reauthenticationToken?: string
|
||||
reauthenticationToken?: string,
|
||||
prompt?: string
|
||||
) => {
|
||||
const res = await this.api.post('/oidc/authorize', {
|
||||
scope,
|
||||
@@ -31,7 +32,8 @@ class OidcService extends APIService {
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
reauthenticationToken
|
||||
reauthenticationToken,
|
||||
prompt
|
||||
});
|
||||
|
||||
return res.data as AuthorizeResponse;
|
||||
|
||||
@@ -62,9 +62,11 @@ export type OidcDeviceCodeInfo = {
|
||||
};
|
||||
|
||||
export type AuthorizeResponse = {
|
||||
code: string;
|
||||
callbackURL: string;
|
||||
issuer: string;
|
||||
code?: string;
|
||||
callbackURL?: string;
|
||||
issuer?: string;
|
||||
error?: string;
|
||||
requiresRedirect?: boolean;
|
||||
};
|
||||
|
||||
export type AccessibleOidcClient = OidcClientMetaData & {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
const oidService = new OidcService();
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let { client, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod, authorizeState } =
|
||||
let { client, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod, authorizeState, prompt } =
|
||||
data;
|
||||
|
||||
let isLoading = $state(false);
|
||||
@@ -30,7 +30,26 @@
|
||||
let authorizationConfirmed = $state(false);
|
||||
let userSignedInAt: Date | undefined;
|
||||
|
||||
// Parse prompt parameter once (space-delimited per OIDC spec)
|
||||
const promptValues = prompt ? prompt.split(' ') : [];
|
||||
const hasPromptNone = promptValues.includes('none');
|
||||
const hasPromptConsent = promptValues.includes('consent');
|
||||
const hasPromptLogin = promptValues.includes('login');
|
||||
const hasPromptSelectAccount = promptValues.includes('select_account');
|
||||
|
||||
onMount(() => {
|
||||
// Conflicting prompt values - none can't be combined with any interactive prompt
|
||||
if (hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount)) {
|
||||
redirectWithError('interaction_required');
|
||||
return;
|
||||
}
|
||||
|
||||
// If prompt=none and user is not signed in, redirect immediately with login_required
|
||||
if (hasPromptNone && !$userStore) {
|
||||
redirectWithError('login_required');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($userStore) {
|
||||
authorize();
|
||||
}
|
||||
@@ -52,6 +71,18 @@
|
||||
|
||||
if (!authorizationConfirmed) {
|
||||
authorizationRequired = await oidService.isAuthorizationRequired(client!.id, scope);
|
||||
|
||||
// If prompt=consent, always show consent UI
|
||||
if (hasPromptConsent) {
|
||||
authorizationRequired = true;
|
||||
}
|
||||
|
||||
// If prompt=none and consent required, redirect with error
|
||||
if (hasPromptNone && authorizationRequired) {
|
||||
redirectWithError('consent_required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (authorizationRequired) {
|
||||
isLoading = false;
|
||||
authorizationConfirmed = true;
|
||||
@@ -60,7 +91,7 @@
|
||||
}
|
||||
|
||||
let reauthToken: string | undefined;
|
||||
if (client?.requiresReauthentication) {
|
||||
if (client?.requiresReauthentication || hasPromptLogin) {
|
||||
let authResponse;
|
||||
const signedInRecently =
|
||||
userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000;
|
||||
@@ -71,22 +102,48 @@
|
||||
reauthToken = await webauthnService.reauthenticate(authResponse);
|
||||
}
|
||||
|
||||
const authResult = await oidService.authorize(
|
||||
const result = await oidService.authorize(
|
||||
client!.id,
|
||||
scope,
|
||||
callbackURL,
|
||||
nonce,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
reauthToken
|
||||
reauthToken,
|
||||
prompt
|
||||
);
|
||||
onSuccess(authResult.code, authResult.callbackURL, authResult.issuer);
|
||||
|
||||
// Check if backend returned a redirect error
|
||||
if (result.requiresRedirect && result.error) {
|
||||
if (hasPromptNone) {
|
||||
redirectWithError(result.error);
|
||||
} else {
|
||||
errorMessage = result.error;
|
||||
isLoading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess(result.code!, result.callbackURL!, result.issuer!);
|
||||
} catch (e) {
|
||||
errorMessage = getWebauthnErrorMessage(e);
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWithError(error: string) {
|
||||
const redirectURL = new URL(callbackURL);
|
||||
if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') {
|
||||
throw new Error('Invalid redirect URL protocol');
|
||||
}
|
||||
|
||||
redirectURL.searchParams.append('error', error);
|
||||
if (authorizeState) {
|
||||
redirectURL.searchParams.append('state', authorizeState);
|
||||
}
|
||||
window.location.href = redirectURL.toString();
|
||||
}
|
||||
|
||||
function onSuccess(code: string, callbackURL: string, issuer: string) {
|
||||
const redirectURL = new URL(callbackURL);
|
||||
if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') {
|
||||
|
||||
@@ -14,6 +14,7 @@ export const load: PageLoad = async ({ url }) => {
|
||||
callbackURL: url.searchParams.get('redirect_uri')!,
|
||||
client,
|
||||
codeChallenge: url.searchParams.get('code_challenge')!,
|
||||
codeChallengeMethod: url.searchParams.get('code_challenge_method')!
|
||||
codeChallengeMethod: url.searchParams.get('code_challenge_method')!,
|
||||
prompt: url.searchParams.get('prompt') || undefined
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ test('Authorize existing client', async ({ page }) => {
|
||||
|
||||
// Ignore DNS resolution error as the callback URL is not reachable
|
||||
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
@@ -31,7 +31,7 @@ test('Authorize existing client while not signed in', async ({ page }) => {
|
||||
|
||||
// Ignore DNS resolution error as the callback URL is not reachable
|
||||
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
@@ -49,7 +49,7 @@ test('Authorize new client', async ({ page }) => {
|
||||
|
||||
// Ignore DNS resolution error as the callback URL is not reachable
|
||||
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
@@ -71,7 +71,7 @@ test('Authorize new client while not signed in', async ({ page }) => {
|
||||
|
||||
// Ignore DNS resolution error as the callback URL is not reachable
|
||||
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
@@ -125,7 +125,7 @@ test('End session with id token hint redirects to callback URL', async ({ page }
|
||||
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
|
||||
)
|
||||
.catch((e) => {
|
||||
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED') || e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
redirectedCorrectly = true;
|
||||
} else {
|
||||
throw e;
|
||||
@@ -617,10 +617,170 @@ test('Forces reauthentication when client requires it', async ({ page, request }
|
||||
await expect(page.getByTestId('scopes')).not.toBeVisible();
|
||||
|
||||
await page.waitForURL(oidcClients.nextcloud.callbackUrl).catch((e) => {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
expect(webauthnStartCalled).toBe(true);
|
||||
});
|
||||
|
||||
test.describe('OIDC prompt parameter', () => {
|
||||
test('prompt=none redirects with login_required when user not authenticated', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.context().clearCookies();
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none');
|
||||
|
||||
// Should redirect to callback URL with error
|
||||
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
'/auth/callback',
|
||||
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
|
||||
);
|
||||
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('login_required');
|
||||
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
|
||||
test('prompt=none redirects with consent_required when authorization needed', async ({
|
||||
page
|
||||
}) => {
|
||||
const oidcClient = oidcClients.immich;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none');
|
||||
|
||||
// Should redirect to callback URL with error
|
||||
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
'/auth/callback',
|
||||
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
|
||||
);
|
||||
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('consent_required');
|
||||
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
|
||||
test('prompt=none succeeds when user is authenticated and authorized', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none');
|
||||
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
// Should redirect successfully to callback URL with code
|
||||
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('prompt=consent forces consent display even for authorized client', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'consent');
|
||||
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
// Should show consent UI even though client was already authorized
|
||||
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
|
||||
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
// Should redirect successfully after consent
|
||||
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('prompt=login forces reauthentication', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'login');
|
||||
|
||||
let reauthCalled = false;
|
||||
await page.route('/api/webauthn/login/start', async (route) => {
|
||||
reauthCalled = true;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await (await passkeyUtil.init(page)).addPasskey();
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
// Should require reauthentication even though user is signed in
|
||||
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
|
||||
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
expect(reauthCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('prompt=select_account returns interaction_required error', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'select_account');
|
||||
|
||||
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||
|
||||
// Should show error since account selection is not supported
|
||||
await expect(
|
||||
page.getByRole('paragraph').filter({ hasText: 'interaction_required' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none consent');
|
||||
|
||||
// Should redirect with error since both can't be satisfied
|
||||
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
'/auth/callback',
|
||||
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
|
||||
);
|
||||
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
|
||||
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
|
||||
test('prompt=none with prompt=login returns interaction_required', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none login');
|
||||
|
||||
// Should redirect with error since both can't be satisfied
|
||||
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
'/auth/callback',
|
||||
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
|
||||
);
|
||||
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
|
||||
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
|
||||
test('prompt=none with prompt=select_account returns interaction_required', async ({ page }) => {
|
||||
const oidcClient = oidcClients.nextcloud;
|
||||
const urlParams = createUrlParams(oidcClient);
|
||||
urlParams.set('prompt', 'none select_account');
|
||||
|
||||
// Should redirect with error since both can't be satisfied
|
||||
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
|
||||
page,
|
||||
'/auth/callback',
|
||||
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
|
||||
);
|
||||
|
||||
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
|
||||
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
export async function interceptCallbackRedirect(
|
||||
page: Page,
|
||||
callbackPath: string,
|
||||
action: () => Promise<void>,
|
||||
timeoutMs: number = 10000
|
||||
): Promise<URL> {
|
||||
const routeMatcher = (url: URL) => url.pathname === callbackPath;
|
||||
|
||||
const callbackPromise = new Promise<URL>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`Timed out waiting for redirect to ${callbackPath}`)),
|
||||
timeoutMs
|
||||
);
|
||||
|
||||
page.route(routeMatcher, async (route) => {
|
||||
clearTimeout(timer);
|
||||
resolve(new URL(route.request().url()));
|
||||
await route.abort();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await action();
|
||||
return await callbackPromise;
|
||||
} finally {
|
||||
await page.unroute(routeMatcher);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserCode(
|
||||
page: Page,
|
||||
clientId: string,
|
||||
|
||||
Reference in New Issue
Block a user