-
-
Notifications
You must be signed in to change notification settings - Fork 204
Description
RFC: Authorizer as Full OIDC Provider
Phase: 3 — Enterprise SSO & Federation
Priority: P2 — High
Estimated Effort: High
Depends on: M2M Auth (#509), Fine-Grained Permissions (#508)
Problem Statement
Authorizer can consume OIDC (social login providers) but cannot act as an OIDC Provider. Third-party applications cannot use "Sign in with Authorizer" for SSO. WorkOS Connect, Clerk as IdP, and Keycloak as IdP all support this. This is essential for organizations that want Authorizer to be their central identity provider for all internal and external applications.
Current Architecture Context
- Partial OAuth endpoints exist:
/authorize,/oauth/token(authorization_code + refresh_token grants),/userinfo /.well-known/openid-configurationendpoint exists but is minimal/.well-known/jwks.jsonendpoint exists and works- Token generation supports HS/RS/ES algorithm families
- PKCE support exists (RFC 7636)
- No client registration mechanism — single
ClientID/ClientSecretper instance - No consent screen — authorization is implicit
- No
code→tokenexchange with proper client authentication for third-party apps
Current /authorize flow (internal/http_handlers/authorize.go):
- Generates authorization code or tokens
- Validates
redirect_uriagainst configured allowed origins - Stores state in memory store
- Redirects to login page if no session
Current /.well-known/openid-configuration (internal/http_handlers/openid_config.go):
- Returns basic OIDC discovery document
- References
/authorize,/oauth/token,/userinfo, JWKS endpoint
Proposed Solution
1. OIDC Client Registration
Third-party applications register with Authorizer to authenticate users.
New schema: internal/storage/schemas/oidc_client.go
type OIDCClient struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
Name string `json:"name" gorm:"type:varchar(256)"`
ClientID string `json:"client_id" gorm:"type:varchar(64);uniqueIndex"`
ClientSecretHash string `json:"-" gorm:"type:varchar(256)"` // for confidential clients
ClientType string `json:"client_type" gorm:"type:varchar(20)"` // "confidential" or "public"
RedirectURIs string `json:"redirect_uris" gorm:"type:text"` // JSON array of allowed redirect URIs
PostLogoutRedirects string `json:"post_logout_redirects" gorm:"type:text"` // JSON array
AllowedScopes string `json:"allowed_scopes" gorm:"type:text"` // comma-separated: "openid,profile,email,roles"
AllowedGrantTypes string `json:"allowed_grant_types" gorm:"type:text"` // comma-separated: "authorization_code,refresh_token"
LogoURL string `json:"logo_url" gorm:"type:text"`
Description string `json:"description" gorm:"type:text"`
OrganizationID string `json:"organization_id" gorm:"type:char(36);index"`
RequirePKCE bool `json:"require_pkce" gorm:"type:bool;default:true"`
RequireConsent bool `json:"require_consent" gorm:"type:bool;default:true"`
TokenEndpointAuth string `json:"token_endpoint_auth" gorm:"type:varchar(50);default:client_secret_basic"`
AccessTokenTTL int64 `json:"access_token_ttl" gorm:"default:1800"` // seconds
RefreshTokenTTL int64 `json:"refresh_token_ttl" gorm:"default:2592000"` // 30 days
IsActive bool `json:"is_active" gorm:"type:bool;default:true"`
CreatedBy string `json:"created_by" gorm:"type:char(36)"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"`
}Client types:
- Confidential: Server-side apps with a client secret (traditional web apps, backend services)
- Public: SPAs, mobile apps, CLIs — no client secret, PKCE mandatory
2. Enhanced Authorization Endpoint
Update internal/http_handlers/authorize.go to support third-party OIDC clients:
func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
return func(c *gin.Context) {
clientID := c.Query("client_id")
redirectURI := c.Query("redirect_uri")
responseType := c.Query("response_type")
scope := c.Query("scope")
state := c.Query("state")
nonce := c.Query("nonce")
codeChallenge := c.Query("code_challenge")
codeChallengeMethod := c.Query("code_challenge_method")
// 1. Look up OIDC client (or fall back to instance client_id for backward compat)
client, err := store.GetOIDCClientByClientID(ctx, clientID)
if client == nil {
// Check if it's the instance's own client_id (backward compatibility)
if clientID == cfg.ClientID {
// Existing behavior — no consent needed
handleLegacyAuthorize(c)
return
}
return oauthError(c, "invalid_client")
}
// 2. Validate redirect_uri against registered URIs
if !isValidRedirectURI(redirectURI, client.RedirectURIs) {
return oauthError(c, "invalid_redirect_uri")
}
// 3. Validate scopes
requestedScopes := parseScopes(scope)
if !areScopesAllowed(requestedScopes, client.AllowedScopes) {
return oauthError(c, "invalid_scope")
}
// 4. PKCE validation
if client.RequirePKCE && codeChallenge == "" {
return oauthError(c, "invalid_request", "code_challenge required")
}
// 5. Check if user is authenticated
user := getAuthenticatedUser(c)
if user == nil {
// Redirect to login page with return URL
redirectToLogin(c, buildReturnURL(c))
return
}
// 6. Consent screen (if required and not previously granted)
if client.RequireConsent {
existingConsent := getExistingConsent(ctx, user.ID, client.ID)
if existingConsent == nil || !scopesCovered(existingConsent.Scopes, requestedScopes) {
// Show consent screen
renderConsentPage(c, client, requestedScopes, user)
return
}
}
// 7. Generate authorization code
code := generateAuthCode(user, client, requestedScopes, nonce, codeChallenge)
// 8. Redirect with code
redirectWithCode(c, redirectURI, code, state)
}
}3. Consent Screen
New endpoint: POST /oauth/consent — handles consent form submission
Consent schema:
type OIDCConsent struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
UserID string `json:"user_id" gorm:"type:char(36);index:idx_consent_user_client,unique"`
ClientID string `json:"client_id" gorm:"type:char(36);index:idx_consent_user_client,unique"`
Scopes string `json:"scopes" gorm:"type:text"`
GrantedAt int64 `json:"granted_at" gorm:"autoCreateTime"`
}Consent page shows:
- Application name and logo (from OIDCClient)
- Requested scopes in human-readable form:
openid→ "Verify your identity"profile→ "Access your name and profile picture"email→ "Access your email address"roles→ "Access your role information"permissions→ "Access your permission list"org→ "Access your organization information"
- "Allow" / "Deny" buttons
Consent page rendered from web/app/ — new React component, or a minimal server-rendered HTML page.
4. Enhanced Token Endpoint
Update /oauth/token to handle third-party OIDC clients:
-
Client authentication: Validate
client_id/client_secretfor confidential clientsclient_secret_basic: HTTP Basic auth headerclient_secret_post: Body parametersnone: Public clients (PKCE required)
-
Authorization code exchange: Validate code was issued to this client, verify PKCE code_verifier
-
Refresh token: Issue refresh tokens only if
offline_accessscope requested and client supportsrefresh_tokengrant type -
Refresh token rotation: When refreshing, invalidate old refresh token and issue new one (security best practice for OIDC)
5. Standard OIDC Scopes
| Scope | Claims Included |
|---|---|
openid |
sub, iss, aud, iat, exp, nonce |
profile |
given_name, family_name, middle_name, nickname, picture, gender, birthdate |
email |
email, email_verified |
phone |
phone_number, phone_number_verified |
roles |
roles (string array) |
permissions |
permissions (string array, requires #508) |
org |
org_id, org_slug, org_role (requires #511) |
offline_access |
Refresh token issued |
6. Enhanced Discovery Document
Update /.well-known/openid-configuration:
{
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/oauth/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
"revocation_endpoint": "https://auth.example.com/oauth/revoke",
"end_session_endpoint": "https://auth.example.com/logout",
"registration_endpoint": "https://auth.example.com/oauth/register",
"scopes_supported": ["openid", "profile", "email", "phone", "roles", "permissions", "org", "offline_access"],
"response_types_supported": ["code", "id_token", "code id_token"],
"grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256", "ES256", "HS256"],
"token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"],
"code_challenge_methods_supported": ["S256"],
"claims_supported": ["sub", "iss", "aud", "exp", "iat", "nonce", "email", "email_verified", "given_name", "family_name", "picture", "roles", "permissions"]
}7. GraphQL Admin API for Client Management
type OIDCClient {
id: ID!
name: String!
client_id: String!
client_type: String!
redirect_uris: [String!]!
allowed_scopes: [String!]!
allowed_grant_types: [String!]!
logo_url: String
require_pkce: Boolean!
require_consent: Boolean!
is_active: Boolean!
created_at: Int64!
}
type OIDCClientWithSecret {
client: OIDCClient!
client_secret: String! # Shown only once
}
type Mutation {
_create_oidc_client(params: CreateOIDCClientInput!): OIDCClientWithSecret!
_update_oidc_client(params: UpdateOIDCClientInput!): OIDCClient!
_delete_oidc_client(id: ID!): Response!
_rotate_oidc_client_secret(id: ID!): OIDCClientWithSecret!
}
type Query {
_oidc_clients(params: PaginatedInput): OIDCClients!
_oidc_client(id: ID!): OIDCClient!
}User-facing query (list authorized applications):
type Query {
authorized_apps: [AuthorizedApp!]! # Apps the user has granted consent to
}
type Mutation {
revoke_app_consent(client_id: ID!): Response! # Revoke consent for an app
}Backward Compatibility
- Existing
/authorizeflow with instanceClientIDcontinues to work unchanged - Existing
/oauth/tokenwithauthorization_codeandrefresh_tokengrants unchanged - New OIDC client system is additive — only activated when third-party clients are registered
- Discovery document enhanced but remains backward compatible
OpenID Certification Path
This implementation targets compliance with:
- OpenID Connect Core 1.0
- OpenID Connect Discovery 1.0
- OpenID Connect Dynamic Client Registration 1.0 (if DCR enabled)
- OAuth 2.0 Multiple Response Types
- PKCE (RFC 7636)
Future: Submit for official OpenID Certification (Phase 6.3).
Testing Plan
- Integration test: full authorization_code flow with PKCE
- Test consent screen display and grant persistence
- Test token exchange with confidential and public clients
- Test redirect_uri validation (exact match, no open redirect)
- Test scope enforcement (only allowed scopes granted)
- Test refresh token rotation
- Test OIDC discovery document compliance
- Test UserInfo endpoint with scoped claims
- Test backward compatibility with instance ClientID