This document describes how holos-console validates OIDC ID tokens and how each validation step conforms to the OpenID Connect specification.
holos-console validates ID tokens using the go-oidc library (v3). Token validation occurs in the LazyAuthInterceptor ConnectRPC interceptor, which protects RPC endpoints that require authentication.
1. Extract Authorization header
2. Verify Bearer token format
3. Validate JWT signature against JWKS
4. Verify token expiration (exp)
5. Verify issuer (iss)
6. Verify audience (aud)
7. Extract and validate claims
8. Extract roles from configured claim
Location: console/rpc/auth.go:96-109
auth := req.Header().Get("Authorization")
if auth == "" {
return nil, connect.NewError(connect.CodeUnauthenticated, nil)
}
const bearerPrefix = "Bearer "
if !strings.HasPrefix(auth, bearerPrefix) {
return nil, connect.NewError(connect.CodeUnauthenticated, nil)
}
token := strings.TrimPrefix(auth, bearerPrefix)
if token == "" {
return nil, connect.NewError(connect.CodeUnauthenticated, nil)
}OIDC Conformance: While not part of the ID Token validation spec itself, this implements RFC 6750 (Bearer Token Usage) for transmitting tokens in the Authorization header.
Location: console/rpc/auth.go:111
idToken, err := verifier.Verify(ctx, token)The verifier.Verify() method validates the JWT signature by:
- Fetching the JSON Web Key Set (JWKS) from the provider's
jwks_uriendpoint - Verifying the token's signature against the appropriate key based on the
kid(Key ID) header - Ensuring the signing algorithm matches supported algorithms (defaults to RS256)
OIDC Conformance: OpenID Connect Core 1.0, Section 3.1.3.7, Step 6:
"The Client MUST validate the signature of all other ID Tokens according to JWS using the algorithm specified in the JWT alg Header Parameter."
Location: Handled by verifier.Verify() at console/rpc/auth.go:111
The go-oidc library automatically verifies that exp (expiration time) has not passed. Expired tokens are rejected with a TokenExpiredError.
OIDC Conformance: OpenID Connect Core 1.0, Section 3.1.3.7, Step 9:
"The current time MUST be before the time represented by the exp Claim."
Location: Handled by verifier.Verify() at console/rpc/auth.go:111
Configuration: The expected issuer is configured via the --issuer CLI flag and passed to LazyAuthInterceptor at console/console.go:203.
The go-oidc library verifies that the token's iss claim exactly matches the configured issuer URL.
OIDC Conformance: OpenID Connect Core 1.0, Section 3.1.3.7, Step 1:
"The Issuer Identifier for the OpenID Provider MUST exactly match the value of the iss (issuer) Claim."
Location: Handled by verifier.Verify() at console/rpc/auth.go:111
Configuration: The expected client ID is configured via the --client-id CLI flag (default: holos-console) and passed to the verifier at console/rpc/auth.go:43-45:
verifier = provider.Verifier(&oidc.Config{
ClientID: clientID,
})The go-oidc library verifies that the token's aud claim contains the configured client ID.
OIDC Conformance: OpenID Connect Core 1.0, Section 3.1.3.7, Step 2:
"The Client MUST validate that the aud (audience) Claim contains its client_id value registered at the Issuer."
Location: console/rpc/auth.go:132-134
if claims.Sub == "" {
claims.Sub = idToken.Subject
}The subject identifier is extracted from the validated token and stored in claims for use by RPC handlers.
OIDC Conformance: OpenID Connect Core 1.0, Section 2:
"sub: REQUIRED. Subject Identifier. A locally unique and never reassigned identifier within the Issuer for the End-User."
Location: console/rpc/auth.go:116-119
var claims Claims
if err := idToken.Claims(&claims); err != nil {
return nil, err
}Claims Structure: console/rpc/claims.go:6-21
type Claims struct {
Sub string `json:"sub"` // Subject identifier
Email string `json:"email"` // User's email
EmailVerified bool `json:"email_verified"` // Email verification status
Name string `json:"name"` // User's full name
Roles []string `json:"groups"` // Role memberships (from configured OIDC claim)
}Location: console/rpc/auth.go:121-129
if rolesClaim != "" && rolesClaim != "groups" {
var rawClaims map[string]interface{}
if err := idToken.Claims(&rawClaims); err == nil {
claims.Roles = ExtractRoles(rawClaims, rolesClaim)
}
}Configuration: The --roles-claim CLI flag (default: "groups") configures which OIDC token claim is used for role membership extraction. This allows integration with identity providers that use non-standard claim names (e.g., realm_roles for Keycloak).
Behavior:
- When
rolesClaimis"groups"(the default), roles are deserialized directly from the token'sgroupsclaim via thejson:"groups"struct tag onClaims.Roles. - When
rolesClaimis set to a custom value (e.g.,"realm_roles"),extractAndVerifyTokenre-parses the token into a rawmap[string]interface{}and callsExtractRoles()(console/rpc/claims.go:25-41) to extract the string array from the specified claim name.
ExtractRoles helper: console/rpc/claims.go:25-41
ExtractRoles handles type assertions safely: it returns nil if the claim is missing or is not a []interface{}. Non-string elements within the array are silently skipped.
Location: console/rpc/auth.go:37-46
provider, err := oidc.NewProvider(oidcCtx, issuer)
if err != nil {
mu.Unlock()
return nil, connect.NewError(connect.CodeUnavailable, err)
}
v = provider.Verifier(&oidc.Config{
ClientID: clientID,
})
verifier = vIf OIDC discovery fails, the error is not cached. The verifier field remains nil so the next request retries discovery. Once discovery succeeds, the verifier is cached permanently.
The oidc.NewProvider() function fetches the OIDC discovery document from {issuer}/.well-known/openid-configuration to obtain:
jwks_uri: URL for fetching signing keysissuer: Canonical issuer identifier- Supported algorithms and other provider metadata
OIDC Conformance: OpenID Connect Discovery 1.0, Section 4
The following validations are NOT performed by holos-console:
The nonce claim is not validated server-side. Per the OIDC spec, nonce validation is primarily a client-side concern to prevent replay attacks during the authorization flow. The frontend (oidc-client-ts) handles nonce validation during the token exchange.
The go-oidc library does not enforce iat validation by default. Per the OIDC spec, this is an OPTIONAL check.
The at_hash claim is not validated. This is only required when an access token is returned alongside the ID token in the authorization response (implicit flow). holos-console uses the authorization code flow with PKCE.
holos-console provides three authentication interceptors:
| Interceptor | Location | Behavior |
|---|---|---|
LazyAuthInterceptor |
auth.go:20-60 | Requires valid token; lazy provider initialization with retry |
AuthInterceptor |
auth.go:64-76 | Requires valid token; immediate provider required |
OptionalAuthInterceptor |
auth.go:80-91 | Validates if present; allows unauthenticated |
Protected endpoints (e.g., SecretsService) use LazyAuthInterceptor configured at console/console.go:203.
TLS certificate verification is always enforced for OIDC discovery connections. When using certificates signed by a custom CA (e.g., mkcert for local development), provide the CA certificate via the --ca-cert flag so the server can verify the issuer's TLS certificate. For example: --ca-cert $(mkcert -CAROOT)/rootCA.pem. In production with publicly trusted certificates, no --ca-cert flag is needed.
Tokens are stored in browser session storage (not local storage) to:
- Survive page refreshes within the same session
- Clear automatically when the browser tab closes
- Isolate sessions between browser tabs
See docs/authentication.md for frontend security details.