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:
Robert Jaakke
2026-04-19 18:03:08 +02:00
committed by GitHub
parent 4f09de2cfc
commit 59fe481af9
12 changed files with 447 additions and 28 deletions

View File

@@ -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 }

View File

@@ -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

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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"))
})
}

View File

@@ -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;

View File

@@ -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 & {

View File

@@ -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:') {

View File

@@ -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
};
};

View File

@@ -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');
});
});

View File

@@ -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,