diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go
index 6982fef3..adc2f6c6 100644
--- a/backend/internal/dto/oidc_dto.go
+++ b/backend/internal/dto/oidc_dto.go
@@ -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"`
diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go
index bd9ca046..68882dc9 100644
--- a/backend/internal/model/oidc.go
+++ b/backend/internal/model/oidc.go
@@ -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"`
diff --git a/backend/internal/oidc/authorization_service.go b/backend/internal/oidc/authorization_service.go
index 93590d6d..7ac07b90 100644
--- a/backend/internal/oidc/authorization_service.go
+++ b/backend/internal/oidc/authorization_service.go
@@ -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,
diff --git a/backend/internal/oidc/authorization_service_test.go b/backend/internal/oidc/authorization_service_test.go
index 6add3972..03b5a8a0 100644
--- a/backend/internal/oidc/authorization_service_test.go
+++ b/backend/internal/oidc/authorization_service_test.go
@@ -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)
+}
diff --git a/backend/internal/oidc/device_service.go b/backend/internal/oidc/device_service.go
index 29661203..882395a3 100644
--- a/backend/internal/oidc/device_service.go
+++ b/backend/internal/oidc/device_service.go
@@ -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{
diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go
index 3af37e8a..ffcee99c 100644
--- a/backend/internal/service/e2etest_service.go
+++ b/backend/internal/service/e2etest_service.go
@@ -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)
diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go
index e8fd2d61..c4d549e6 100644
--- a/backend/internal/service/oidc_service.go
+++ b/backend/internal/service/oidc_service.go
@@ -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
diff --git a/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.down.sql b/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.down.sql
new file mode 100644
index 00000000..f538edd8
--- /dev/null
+++ b/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.down.sql
@@ -0,0 +1 @@
+ALTER TABLE oidc_clients DROP COLUMN skip_consent;
diff --git a/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.up.sql b/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.up.sql
new file mode 100644
index 00000000..21bf54bc
--- /dev/null
+++ b/backend/resources/migrations/postgres/20260626120000_oidc_client_skip_consent.up.sql
@@ -0,0 +1 @@
+ALTER TABLE oidc_clients ADD COLUMN skip_consent BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.down.sql b/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.down.sql
new file mode 100644
index 00000000..d93f9ae6
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.down.sql
@@ -0,0 +1,5 @@
+PRAGMA foreign_keys=OFF;
+BEGIN;
+ALTER TABLE oidc_clients DROP COLUMN skip_consent;
+COMMIT;
+PRAGMA foreign_keys=ON;
diff --git a/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.up.sql b/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.up.sql
new file mode 100644
index 00000000..7aa76373
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20260626120000_oidc_client_skip_consent.up.sql
@@ -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;
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 4d84fa0a..eae64b8d 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -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",
diff --git a/frontend/src/lib/types/oidc.type.ts b/frontend/src/lib/types/oidc.type.ts
index aa9b04d5..6cec6e4e 100644
--- a/frontend/src/lib/types/oidc.type.ts
+++ b/frontend/src/lib/types/oidc.type.ts
@@ -28,6 +28,7 @@ export type OidcClient = OidcClientMetaData & {
pkceEnabled: boolean;
requiresReauthentication: boolean;
requiresPushedAuthorizationRequests: boolean;
+ skipConsent: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
isGroupRestricted: boolean;
diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte
index 42556a82..05edd566 100644
--- a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte
+++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-form.svelte
@@ -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}
/>
+