auth: add Keycloak OIDC verifier, role check, and test mock
This commit is contained in:
41
backend/internal/auth/mock/mock.go
Normal file
41
backend/internal/auth/mock/mock.go
Normal file
@@ -0,0 +1,41 @@
|
||||
// Package mock provides a fake auth.Verifier for tests that need to exercise
|
||||
// internal/api's session endpoints and RequireSession gating without a real
|
||||
// Keycloak instance.
|
||||
package mock
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
|
||||
"geniusrun/backend/internal/auth"
|
||||
)
|
||||
|
||||
// Verifier is a fake auth.Verifier returning canned results supplied by the
|
||||
// test/caller.
|
||||
type Verifier struct {
|
||||
AuthURL string
|
||||
Txn auth.TxnState
|
||||
CallbackResult auth.LoginResult
|
||||
CallbackErr error
|
||||
EndSessionResult string // if empty, EndSessionURL returns postLogoutRedirectURL unchanged
|
||||
}
|
||||
|
||||
var _ auth.Verifier = (*Verifier)(nil)
|
||||
|
||||
func (v *Verifier) BeginLogin() (string, auth.TxnState, error) {
|
||||
return v.AuthURL, v.Txn, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) HandleCallback(ctx context.Context, txn auth.TxnState, query url.Values) (auth.LoginResult, error) {
|
||||
if v.CallbackErr != nil {
|
||||
return auth.LoginResult{}, v.CallbackErr
|
||||
}
|
||||
return v.CallbackResult, nil
|
||||
}
|
||||
|
||||
func (v *Verifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
if v.EndSessionResult != "" {
|
||||
return v.EndSessionResult
|
||||
}
|
||||
return postLogoutRedirectURL
|
||||
}
|
||||
159
backend/internal/auth/oidc.go
Normal file
159
backend/internal/auth/oidc.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Verifier is the seam between internal/api and this package's real
|
||||
// Keycloak-backed OIDC client, so tests can inject internal/auth/mock's fake
|
||||
// instead of talking to a real identity provider.
|
||||
type Verifier interface {
|
||||
// BeginLogin builds a Keycloak authorization URL and the transaction
|
||||
// state that must be round-tripped (via a TxnCookie) to HandleCallback.
|
||||
BeginLogin() (authURL string, txn TxnState, err error)
|
||||
// HandleCallback validates the callback query against txn, exchanges the
|
||||
// code, verifies the ID token, and reports whether the required role was
|
||||
// present.
|
||||
HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error)
|
||||
// EndSessionURL builds the identity provider's logout URL, redirecting
|
||||
// back to postLogoutRedirectURL once Keycloak's own session is cleared.
|
||||
EndSessionURL(postLogoutRedirectURL string) string
|
||||
}
|
||||
|
||||
// LoginResult is what a completed callback exchange resolves to.
|
||||
type LoginResult struct {
|
||||
Claims Claims
|
||||
Authorized bool
|
||||
}
|
||||
|
||||
// OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the
|
||||
// ID token's realm_access.roles.
|
||||
type OIDCConfig struct {
|
||||
IssuerURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RedirectURL string
|
||||
RequiredRole string
|
||||
}
|
||||
|
||||
// idTokenClaims mirrors the subset of a Keycloak ID token's claims geniusrun
|
||||
// cares about.
|
||||
type idTokenClaims struct {
|
||||
Sub string `json:"sub"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
RealmAccess struct {
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"realm_access"`
|
||||
}
|
||||
|
||||
func (c idTokenClaims) hasRole(required string) bool {
|
||||
for _, r := range c.RealmAccess.Roles {
|
||||
if r == required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type oidcVerifier struct {
|
||||
provider *oidc.Provider
|
||||
idTokenVerif *oidc.IDTokenVerifier
|
||||
oauth2Config oauth2.Config
|
||||
requiredRole string
|
||||
}
|
||||
|
||||
// NewOIDCVerifier performs OIDC discovery against cfg.IssuerURL (once, at
|
||||
// startup -- go-oidc caches the discovery document internally) and returns a
|
||||
// Verifier backed by the real Keycloak realm.
|
||||
func NewOIDCVerifier(ctx context.Context, cfg OIDCConfig) (Verifier, error) {
|
||||
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery against %s: %w", cfg.IssuerURL, err)
|
||||
}
|
||||
return &oidcVerifier{
|
||||
provider: provider,
|
||||
idTokenVerif: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
||||
oauth2Config: oauth2.Config{
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: cfg.RedirectURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
requiredRole: cfg.RequiredRole,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func randomString(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate random state: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func (v *oidcVerifier) BeginLogin() (string, TxnState, error) {
|
||||
state, err := randomString(24)
|
||||
if err != nil {
|
||||
return "", TxnState{}, err
|
||||
}
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
authURL := v.oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||||
return authURL, TxnState{State: state, CodeVerifier: verifier}, nil
|
||||
}
|
||||
|
||||
func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query url.Values) (LoginResult, error) {
|
||||
if got := query.Get("state"); got == "" || got != txn.State {
|
||||
return LoginResult{}, fmt.Errorf("state mismatch")
|
||||
}
|
||||
code := query.Get("code")
|
||||
if code == "" {
|
||||
return LoginResult{}, fmt.Errorf("callback missing code")
|
||||
}
|
||||
token, err := v.oauth2Config.Exchange(ctx, code, oauth2.VerifierOption(txn.CodeVerifier))
|
||||
if err != nil {
|
||||
return LoginResult{}, fmt.Errorf("exchange code: %w", err)
|
||||
}
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return LoginResult{}, fmt.Errorf("token response missing id_token")
|
||||
}
|
||||
idToken, err := v.idTokenVerif.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return LoginResult{}, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
var claims idTokenClaims
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
return LoginResult{
|
||||
Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email},
|
||||
Authorized: claims.hasRole(v.requiredRole),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (v *oidcVerifier) EndSessionURL(postLogoutRedirectURL string) string {
|
||||
var discovery struct {
|
||||
EndSessionEndpoint string `json:"end_session_endpoint"`
|
||||
}
|
||||
if err := v.provider.Claims(&discovery); err != nil || discovery.EndSessionEndpoint == "" {
|
||||
return postLogoutRedirectURL
|
||||
}
|
||||
u, err := url.Parse(discovery.EndSessionEndpoint)
|
||||
if err != nil {
|
||||
return postLogoutRedirectURL
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("client_id", v.oauth2Config.ClientID)
|
||||
q.Set("post_logout_redirect_uri", postLogoutRedirectURL)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
31
backend/internal/auth/oidc_test.go
Normal file
31
backend/internal/auth/oidc_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIDTokenClaims_HasRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
role string
|
||||
want bool
|
||||
}{
|
||||
{"role present among others", `{"realm_access":{"roles":["geniusrun-user","other"]}}`, "geniusrun-user", true},
|
||||
{"role absent", `{"realm_access":{"roles":["other"]}}`, "geniusrun-user", false},
|
||||
{"realm_access missing entirely", `{}`, "geniusrun-user", false},
|
||||
{"roles array empty", `{"realm_access":{"roles":[]}}`, "geniusrun-user", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var c idTokenClaims
|
||||
if err := json.Unmarshal([]byte(tc.json), &c); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if got := c.hasRole(tc.role); got != tc.want {
|
||||
t.Errorf("hasRole(%q) = %v, want %v", tc.role, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user