feat: add ability to skip consent for client

This commit is contained in:
Elias Schneider
2026-06-26 23:35:26 +02:00
parent 16b5c16a66
commit d467855870
18 changed files with 225 additions and 14 deletions

View File

@@ -18,6 +18,7 @@ type OidcClientDto struct {
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
SkipConsent bool `json:"skipConsent"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
@@ -40,6 +41,7 @@ type OidcClientUpdateDto struct {
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
SkipConsent bool `json:"skipConsent"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`

View File

@@ -32,6 +32,7 @@ type OidcClient struct {
PkceEnabled bool `sortable:"true" filterable:"true"`
RequiresReauthentication bool `sortable:"true" filterable:"true"`
RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"`
SkipConsent bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
IsGroupRestricted bool `sortable:"true" filterable:"true"`

View File

@@ -57,6 +57,15 @@ func (p promptValues) has(value string) bool {
return slices.Contains(p, value)
}
// consentRequired reports whether the user has to be shown the consent screen
// A client can be configured to skip consent so trusted first-party apps are not prompted on every new authorization, but an explicit prompt=consent always forces the screen regardless of that setting
func consentRequired(hasAlreadyAuthorizedClient, clientSkipsConsent bool, prompt promptValues) bool {
if prompt.has("consent") {
return true
}
return !hasAlreadyAuthorizedClient && !clientSkipsConsent
}
// authorizeInput is the authorization request as provided by the handler.
type authorizeInput struct {
userID string
@@ -236,7 +245,7 @@ func (s *authorizationService) resolveRequirements(ctx context.Context, req auth
}
requirements := interactionRequirements{
ConsentRequired: !hasAlreadyAuthorizedClient || req.prompt.has("consent"),
ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, req.client.SkipConsent, req.prompt),
ReauthenticationRequired: req.prompt.has("login") || req.client.RequiresReauthentication || maxAgeReauthenticationRequired,
AccountSelectionRequired: req.prompt.has("select_account"),
AuthenticationRequired: false,
@@ -568,7 +577,7 @@ func (s *authorizationService) interactionRequirementsForUser(ctx context.Contex
}
return interactionRequirements{
ConsentRequired: !hasAlreadyAuthorizedClient || prompt.has("consent"),
ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, interactionSession.Client.SkipConsent, prompt),
ReauthenticationRequired: prompt.has("login") || interactionSession.Client.RequiresReauthentication || maxAgeReauthenticationRequired,
AccountSelectionRequired: prompt.has("select_account"),
AuthenticationRequired: false,

View File

@@ -913,3 +913,89 @@ func newTestAuthorizeRequesterWithForm(requestID, clientID string, form url.Valu
func stringPointer(value string) *string {
return &value
}
func TestConsentRequired(t *testing.T) {
tests := []struct {
name string
hasAlreadyAuthorized bool
skipConsent bool
prompt string
want bool
}{
{"new client without skip requires consent", false, false, "", true},
{"returning client without skip skips consent", true, false, "", false},
{"new client with skip skips consent", false, true, "", false},
{"returning client with skip skips consent", true, true, "", false},
{"prompt=consent forces consent despite skip", false, true, "consent", true},
{"prompt=consent forces consent for returning client", true, false, "consent", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := consentRequired(tt.hasAlreadyAuthorized, tt.skipConsent, newPromptValues(tt.prompt))
require.Equal(t, tt.want, got)
})
}
}
// A client with SkipConsent must be granted without a consent interaction even on the first authorization, while consent is still recorded so the user can later see and revoke the client
func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
const (
userID = "test-user"
clientID = "test-client"
)
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}).Error)
// No prior UserAuthorizedOidcClient record exists, so a client without skip-consent would require the consent screen here
requester := newTestAuthorizeRequester("skip-consent-request", clientID, "")
requester.(*fosite.AuthorizeRequest).Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}}
authorization, err := service.authorize(t.Context(), authorizeInput{
userID: userID,
authenticationTime: time.Now().UTC(),
requester: requester,
meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
})
require.NoError(t, err)
require.False(t, authorization.RequiresInteraction)
require.NotNil(t, authorization.Session)
var count int64
require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).Where("user_id = ? AND client_id = ?", userID, clientID).Count(&count).Error)
require.Equal(t, int64(1), count)
require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
}
// A client with SkipConsent must still show the consent screen when the request explicitly asks for it with prompt=consent
func TestAuthorizationServiceSkipConsentHonorsPromptConsent(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
const (
userID = "test-user"
clientID = "test-client"
)
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}).Error)
requester := newTestAuthorizeRequester("skip-consent-prompt-request", clientID, "consent")
requester.(*fosite.AuthorizeRequest).Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client", SkipConsent: true}}
authorization, err := service.authorize(t.Context(), authorizeInput{
userID: userID,
authenticationTime: time.Now().UTC(),
requester: requester,
meta: requestMeta{},
})
require.NoError(t, err)
require.True(t, authorization.RequiresInteraction)
interactionSession, err := newInteractionSessionService(db).get(t.Context(), authorization.InteractionID)
require.NoError(t, err)
require.True(t, interactionSession.ConsentRequired)
}

View File

@@ -157,7 +157,8 @@ func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID
if err != nil {
return nil, err
}
authorizationRequired = !hasAuthorizedClient
// The device flow has no per-request prompt parameter, so consent depends only on prior authorization and the client's skip-consent setting
authorizationRequired = consentRequired(hasAuthorizedClient, client.SkipConsent, nil)
}
return &dto.DeviceCodeInfoDto{

View File

@@ -257,6 +257,17 @@ func (s *TestService) SeedDatabase(baseURL string) error {
CallbackURLs: model.UrlList{"http://par-client.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
},
{
Base: model.Base{
ID: "e1f2a3b4-c5d6-7890-abcd-ef0000000002",
},
Name: "Skip Consent Client",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://skip-consent.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
// Trusted client that bypasses the consent screen by default
SkipConsent: true,
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
@@ -770,7 +781,7 @@ func (s *TestService) newFositeTokenRequest(session fositeTokenSession) *fosite.
RequestedAt: requestedAt,
AuthTime: requestedAt,
Subject: session.UserID,
Issuer: common.EnvConfig.AppURL,
Issuer: common.EnvConfig.AppURL,
},
}
oidcSession.SetExpiresAt(session.TokenType, session.ExpiresAt)

View File

@@ -220,6 +220,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication
client.RequiresPushedAuthorizationRequests = input.RequiresPushedAuthorizationRequests
client.SkipConsent = input.SkipConsent
client.LaunchURL = input.LaunchURL
client.IsGroupRestricted = input.IsGroupRestricted

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN skip_consent;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN skip_consent BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients DROP COLUMN skip_consent;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients ADD COLUMN skip_consent BOOLEAN NOT NULL DEFAULT FALSE;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -182,6 +182,8 @@
"smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option",
"skip_certificate_verification": "Skip Certificate Verification",
"skip_consent": "Skip Consent Screen",
"skip_consent_description": "Don't ask users to approve this client and the scopes it requests. Only enable this for trusted first-party clients.",
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
"enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification",

View File

@@ -28,6 +28,7 @@ export type OidcClient = OidcClientMetaData & {
pkceEnabled: boolean;
requiresReauthentication: boolean;
requiresPushedAuthorizationRequests: boolean;
skipConsent: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
isGroupRestricted: boolean;

View File

@@ -51,6 +51,7 @@
requiresReauthentication: existingClient?.requiresReauthentication || false,
requiresPushedAuthorizationRequests:
existingClient?.requiresPushedAuthorizationRequests || false,
skipConsent: existingClient?.skipConsent || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
@@ -77,6 +78,7 @@
pkceEnabled: z.boolean(),
requiresReauthentication: z.boolean(),
requiresPushedAuthorizationRequests: z.boolean(),
skipConsent: z.boolean(),
launchURL: optionalUrl,
logoUrl: optionalUrl,
darkLogoUrl: optionalUrl,
@@ -226,6 +228,12 @@
description={m.requires_users_to_authenticate_again_on_each_authorization()}
bind:checked={$inputs.requiresReauthentication.value}
/>
<SwitchWithLabel
id="skip-consent"
label={m.skip_consent()}
description={m.skip_consent_description()}
bind:checked={$inputs.skipConsent.value}
/>
</div>
<div class="mt-7 w-full md:w-1/2">
<Tabs.Root value="light-logo">

View File

@@ -73,6 +73,12 @@ export const oidcClients = {
name: 'PAR Test Client',
callbackUrl: 'http://par-client.localhost/auth/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
},
skipConsent: {
id: 'e1f2a3b4-c5d6-7890-abcd-ef0000000002',
name: 'Skip Consent Client',
callbackUrl: 'http://skip-consent.localhost/auth/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
}
};

View File

@@ -1,6 +1,6 @@
{
"provider": "sqlite",
"version": 20260607120000,
"version": 20260626120000,
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
"tables": {
"api_keys": [
@@ -56,7 +56,8 @@
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
"skip_consent": false
},
{
"callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
@@ -74,7 +75,8 @@
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
"skip_consent": false
},
{
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
@@ -92,7 +94,8 @@
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a"
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a",
"skip_consent": false
},
{
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
@@ -110,7 +113,8 @@
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe",
"skip_consent": false
},
{
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
@@ -127,7 +131,8 @@
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK"
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK",
"skip_consent": false
},
{
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
@@ -145,7 +150,27 @@
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
"skip_consent": false
},
{
"callback_urls": "WyJodHRwOi8vc2tpcC1jb25zZW50LmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"credentials": "e30=",
"dark_image_type": null,
"id": "e1f2a3b4-c5d6-7890-abcd-ef0000000002",
"image_type": null,
"is_group_restricted": false,
"is_public": false,
"launch_url": null,
"logout_callback_urls": "bnVsbA==",
"name": "Skip Consent Client",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC",
"skip_consent": true
}
],
"oidc_clients_allowed_user_groups": [

View File

@@ -11,7 +11,7 @@ test('Dashboard shows all clients in the correct order', async ({ page }) => {
await page.goto('/settings/apps');
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(6);
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(7);
// Should be first
const card1 = page.getByTestId('authorized-oidc-client-card').first();
@@ -32,7 +32,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
const cards = page.getByTestId('authorized-oidc-client-card');
await expect(cards).toHaveCount(5);
await expect(cards).toHaveCount(6);
const cardTexts = await cards.allTextContents();
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
@@ -40,7 +40,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
test('User can see all clients', async ({ page }) => {
await page.goto('/settings/apps');
const cards = page.getByTestId('authorized-oidc-client-card');
await expect(cards).toHaveCount(6);
await expect(cards).toHaveCount(7);
});
});

View File

@@ -1440,3 +1440,49 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
await expect(savedToggle).toBeChecked();
});
});
test.describe('OIDC skip consent', () => {
// This seeded client has skip-consent enabled and is not pre-authorized for the signed-in
// user, so the consent screen would normally be shown on first authorization.
const client = oidcClients.skipConsent;
test('skips the consent screen on first authorization', async ({ page }) => {
// The flow must go straight through to the callback instead of stopping at the consent screen
const urlParams = createUrlParams(client);
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
expect(callbackUrl.searchParams.get('code')).toBeTruthy();
expect(callbackUrl.searchParams.get('error')).toBeNull();
});
test('still shows the consent screen when prompt=consent is requested', async ({ page }) => {
const urlParams = createUrlParams(client);
urlParams.set('prompt', 'consent');
await page.goto(`/authorize?${urlParams.toString()}`);
// prompt=consent overrides the client's skip-consent setting, so the scopes must still be shown
await expectScopes(page, ['Email', 'Profile']);
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
);
expect(callbackUrl.searchParams.get('code')).toBeTruthy();
});
test('Admin UI: skip consent toggle reflects the seeded value and persists', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
// The seeded client has skip-consent enabled, so the toggle loads checked
const toggle = page.getByRole('switch', { name: 'Skip Consent Screen' });
await expect(toggle).toBeChecked();
// Disabling it and saving must persist across a reload
await toggle.click();
await page.getByRole('button', { name: /save/i }).click();
await page.reload();
await expect(page.getByRole('switch', { name: 'Skip Consent Screen' })).not.toBeChecked();
});
});