diff --git a/auth/auth.go b/auth/auth.go index 0941353e8..6ae509fd3 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -16,6 +16,7 @@ import ( "github.com/OpenSlides/openslides-go/environment" "github.com/OpenSlides/openslides-go/oserror" "github.com/golang-jwt/jwt/v4" + "github.com/jackc/pgx/v5/pgxpool" "github.com/ostcar/topic" ) @@ -33,6 +34,12 @@ var ( envAuthTokenFile = environment.NewVariable("AUTH_TOKEN_KEY_FILE", "/run/secrets/auth_token_key", "Key to sign the JWT auth tocken.") envAuthCookieFile = environment.NewVariable("AUTH_COOKIE_KEY_FILE", "/run/secrets/auth_cookie_key", "Key to sign the JWT auth cookie.") + + // OIDC environment variables + envOIDCEnabled = environment.NewVariable("OIDC_ENABLED", "false", "Enable OIDC authentication.") + envOIDCIssuerURL = environment.NewVariable("OIDC_ISSUER_URL", "", "Keycloak Realm URL (external) for issuer validation.") + envOIDCInternalIssuerURL = environment.NewVariable("OIDC_INTERNAL_ISSUER_URL", "", "Keycloak Realm URL (internal) for JWKS discovery. Defaults to OIDC_ISSUER_URL.") + envOIDCClientID = environment.NewVariable("OIDC_CLIENT_ID", "", "Expected audience in OIDC token.") ) // pruneTime defines how long a topic id will be valid. This should be higher @@ -65,13 +72,21 @@ type Auth struct { tokenKey string cookieKey string + + // OIDC fields + oidcEnabled bool + oidcValidator *OIDCValidator + userLookup *UserLookup } // New initializes the Auth object. // -// Returns the initialized Auth objectand a function to be called in the +// Returns the initialized Auth object and a function to be called in the // background. -func New(lookup environment.Environmenter, messageBus LogoutEventer) (*Auth, func(context.Context, func(error)), error) { +// +// The pool parameter is optional and only needed for OIDC mode to lookup users +// by keycloak_id. +func New(lookup environment.Environmenter, messageBus LogoutEventer, pool ...*pgxpool.Pool) (*Auth, func(context.Context, func(error)), error) { url := fmt.Sprintf( "%s://%s:%s", envAuthProtocol.Value(lookup), @@ -91,12 +106,40 @@ func New(lookup environment.Environmenter, messageBus LogoutEventer) (*Auth, fun return nil, nil, fmt.Errorf("reading cookie token: %w", err) } + // OIDC configuration + oidcEnabled, _ := strconv.ParseBool(envOIDCEnabled.Value(lookup)) + + var oidcValidator *OIDCValidator + var userLookup *UserLookup + + if oidcEnabled { + issuerURL := envOIDCIssuerURL.Value(lookup) + internalIssuerURL := envOIDCInternalIssuerURL.Value(lookup) + clientID := envOIDCClientID.Value(lookup) + if issuerURL == "" || clientID == "" { + return nil, nil, fmt.Errorf("OIDC enabled but OIDC_ISSUER_URL or OIDC_CLIENT_ID not set") + } + // Use internal URL for JWKS if provided, otherwise use issuer URL + if internalIssuerURL == "" { + internalIssuerURL = issuerURL + } + oidcValidator = NewOIDCValidator(issuerURL, internalIssuerURL, clientID) + + if len(pool) == 0 || pool[0] == nil { + return nil, nil, fmt.Errorf("OIDC enabled but no database pool provided") + } + userLookup = NewUserLookup(pool[0]) + } + a := &Auth{ fake: fake, logedoutSessions: topic.New[string](), authServiceURL: url, tokenKey: authToken, cookieKey: cookieToken, + oidcEnabled: oidcEnabled, + oidcValidator: oidcValidator, + userLookup: userLookup, } // Make sure the topic is not empty @@ -115,7 +158,6 @@ func New(lookup environment.Environmenter, messageBus LogoutEventer) (*Auth, fun } // Authenticate uses the headers from the given request to get the user id. The -// returned context will be cancled, if the session is revoked. func (a *Auth) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) { if a.fake { return r.Context(), nil @@ -134,13 +176,13 @@ func (a *Auth) Authenticate(w http.ResponseWriter, r *http.Request) (context.Con cid, sessionIDs := a.logedoutSessions.ReceiveAll() if slices.Contains(sessionIDs, p.SessionID) { - return nil, &authError{"invalid session", nil} + return nil, &authError{msg: "invalid session", status: http.StatusUnauthorized} } - ctx, cancelCtx := context.WithCancel(a.AuthenticatedContext(ctx, p.UserID)) + ctx, cancelCtx := context.WithCancelCause(a.AuthenticatedContext(ctx, p.UserID)) go func() { - defer cancelCtx() + defer cancelCtx(nil) var sessionIDs []string var err error @@ -151,6 +193,8 @@ func (a *Auth) Authenticate(w http.ResponseWriter, r *http.Request) (context.Con } if slices.Contains(sessionIDs, p.SessionID) { + // Cancel with LogoutError to signal server-initiated logout + cancelCtx(LogoutError{SessionID: p.SessionID}) return } } @@ -223,7 +267,7 @@ func (a *Auth) pruneOldData(ctx context.Context) { // loadToken loads and validates the token. If the token is expires, it tries // to renews it and writes the new token to the responsewriter. -func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, payload jwt.Claims) error { +func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, p *payload) error { header := r.Header.Get(authHeader) cookie, err := r.Cookie(cookieName) if err != nil && err != http.ErrNoCookie { @@ -231,18 +275,39 @@ func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, payload jwt.Cla } encodedToken := strings.TrimPrefix(header, "bearer ") + hasBearerToken := header != encodedToken - if cookie == nil && header == encodedToken { - // No token and no auth cookie. Handle the request as public access requst. + // No token and no auth cookie - public access + if cookie == nil && !hasBearerToken { return nil } - if cookie == nil && header != encodedToken { - return authError{"Can not find auth cookie", nil} + // Try OIDC validation if enabled and we have a bearer token (without cookie) + if a.oidcEnabled && a.oidcValidator != nil && hasBearerToken && cookie == nil { + claims, err := a.oidcValidator.ValidateToken(r.Context(), encodedToken) + if err == nil { + // OIDC token valid - lookup user ID by keycloak_id + userID, err := a.userLookup.GetUserIDByKeycloakID(r.Context(), claims.KeycloakID) + if err != nil { + return authError{msg: fmt.Sprintf("user not found: %s", claims.KeycloakID), wrapped: err} + } + + // Set user ID in payload + p.UserID = userID + p.SessionID = claims.SessionID + return nil + } + // OIDC validation failed - return error (no cookie means no legacy fallback) + return authError{msg: "Invalid OIDC token", wrapped: err} + } + + // Legacy validation requires both cookie and token + if cookie == nil && hasBearerToken { + return authError{msg: "Can not find auth cookie"} } - if cookie != nil && header == encodedToken { - return authError{"Can not find auth token", nil} + if cookie != nil && !hasBearerToken { + return authError{msg: "Can not find auth token"} } encodedCookie := strings.TrimPrefix(cookie.Value, "bearer%20") @@ -253,12 +318,12 @@ func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, payload jwt.Cla if err != nil { var invalid *jwt.ValidationError if errors.As(err, &invalid) { - return authError{"Invalid auth token", err} + return authError{msg: "Invalid auth token", wrapped: err} } return fmt.Errorf("validating auth cookie: %w", err) } - _, err = jwt.ParseWithClaims(encodedToken, payload, func(token *jwt.Token) (interface{}, error) { + _, err = jwt.ParseWithClaims(encodedToken, p, func(token *jwt.Token) (interface{}, error) { return []byte(a.tokenKey), nil }) if err != nil { @@ -273,7 +338,7 @@ func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, payload jwt.Cla func (a *Auth) handleInvalidToken(ctx context.Context, invalid *jwt.ValidationError, w http.ResponseWriter, encodedToken, encodedCookie string) error { if !tokenExpired(invalid.Errors) { - return authError{"Invalid auth token", invalid} + return authError{msg: "Invalid auth token", wrapped: invalid} } token, err := a.refreshToken(ctx, encodedToken, encodedCookie) @@ -324,7 +389,7 @@ func (a *Auth) refreshToken(ctx context.Context, token, cookie string) (string, if rPayload.Message == "" { rPayload.Message = "Can not refresh token" } - return "", authError{rPayload.Message, nil} + return "", authError{msg: rPayload.Message} } @@ -342,3 +407,13 @@ type payload struct { UserID int `json:"userId"` SessionID string `json:"sessionId"` } + +// LogoutError indicates that a session was terminated due to logout. +// This error is used as the cause when cancelling a context due to backchannel logout. +type LogoutError struct { + SessionID string +} + +func (e LogoutError) Error() string { + return "session logged out" +} diff --git a/auth/authtest/oidc.go b/auth/authtest/oidc.go new file mode 100644 index 000000000..c41fa961c --- /dev/null +++ b/auth/authtest/oidc.go @@ -0,0 +1,112 @@ +package authtest + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +// OIDCTestServer provides a mock Keycloak JWKS endpoint for testing +type OIDCTestServer struct { + Server *httptest.Server + PrivateKey *rsa.PrivateKey + IssuerURL string + ClientID string +} + +// NewOIDCTestServer creates a test server with mock JWKS endpoint +func NewOIDCTestServer(clientID string) (*OIDCTestServer, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + ots := &OIDCTestServer{ + PrivateKey: privateKey, + ClientID: clientID, + } + + mux := http.NewServeMux() + mux.HandleFunc("/protocol/openid-connect/certs", ots.handleJWKS) + + ots.Server = httptest.NewServer(mux) + ots.IssuerURL = ots.Server.URL + + return ots, nil +} + +func (ots *OIDCTestServer) handleJWKS(w http.ResponseWriter, r *http.Request) { + jwks := map[string]interface{}{ + "keys": []map[string]string{{ + "kid": "test-key-id", + "kty": "RSA", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(ots.PrivateKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(ots.PrivateKey.E)).Bytes()), + }}, + } + json.NewEncoder(w).Encode(jwks) +} + +// CreateToken creates a valid OIDC token for testing +func (ots *OIDCTestServer) CreateToken(keycloakID string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": ots.IssuerURL, + "aud": ots.ClientID, + "sub": keycloakID, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + "preferred_username": "testuser", + "email": "test@example.com", + }) + token.Header["kid"] = "test-key-id" + return token.SignedString(ots.PrivateKey) +} + +// CreateTokenWithClaims creates an OIDC token with custom claims for testing +func (ots *OIDCTestServer) CreateTokenWithClaims(claims jwt.MapClaims) (string, error) { + // Set defaults if not provided + if _, ok := claims["iss"]; !ok { + claims["iss"] = ots.IssuerURL + } + if _, ok := claims["aud"]; !ok { + claims["aud"] = ots.ClientID + } + if _, ok := claims["exp"]; !ok { + claims["exp"] = time.Now().Add(time.Hour).Unix() + } + if _, ok := claims["iat"]; !ok { + claims["iat"] = time.Now().Unix() + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = "test-key-id" + return token.SignedString(ots.PrivateKey) +} + +// CreateExpiredToken creates an expired OIDC token for testing +func (ots *OIDCTestServer) CreateExpiredToken(keycloakID string) (string, error) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": ots.IssuerURL, + "aud": ots.ClientID, + "sub": keycloakID, + "exp": time.Now().Add(-time.Hour).Unix(), // Expired + "iat": time.Now().Add(-2 * time.Hour).Unix(), + "preferred_username": "testuser", + "email": "test@example.com", + }) + token.Header["kid"] = "test-key-id" + return token.SignedString(ots.PrivateKey) +} + +// Close shuts down the test server +func (ots *OIDCTestServer) Close() { + ots.Server.Close() +} diff --git a/auth/error.go b/auth/error.go index 0a9473560..633d1d654 100644 --- a/auth/error.go +++ b/auth/error.go @@ -3,6 +3,7 @@ package auth type authError struct { msg string wrapped error + status int } func (authError) Type() string { @@ -18,5 +19,8 @@ func (a authError) Unwrap() error { } func (a authError) StatusCode() int { + if a.status != 0 { + return a.status + } return 403 } diff --git a/auth/oidc.go b/auth/oidc.go new file mode 100644 index 000000000..dac9db66f --- /dev/null +++ b/auth/oidc.go @@ -0,0 +1,204 @@ +package auth + +import ( + "context" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math/big" + "net/http" + "sync" + "time" + + "github.com/golang-jwt/jwt/v4" +) + +// OIDCValidator validates Keycloak/OIDC tokens via JWKS +type OIDCValidator struct { + issuerURL string + clientID string + jwksURL string + + mu sync.RWMutex + keys map[string]*rsa.PublicKey + expiresAt time.Time +} + +// NewOIDCValidator creates a new OIDC token validator +// issuerURL is used for validating the token's issuer claim (external URL) +// internalIssuerURL is used for fetching JWKS (can be internal/docker network URL) +func NewOIDCValidator(issuerURL, internalIssuerURL, clientID string) *OIDCValidator { + return &OIDCValidator{ + issuerURL: issuerURL, + clientID: clientID, + jwksURL: internalIssuerURL + "/protocol/openid-connect/certs", + keys: make(map[string]*rsa.PublicKey), + } +} + +// OIDCClaims represents Keycloak token claims +type OIDCClaims struct { + jwt.RegisteredClaims + KeycloakID string `json:"sub"` + SessionID string `json:"sid"` // Keycloak session ID + Email string `json:"email"` + Username string `json:"preferred_username"` + AuthorizedParty string `json:"azp"` // Keycloak puts client_id here instead of aud +} + +// ValidateToken validates a Keycloak JWT token and returns claims +func (v *OIDCValidator) ValidateToken(ctx context.Context, tokenString string) (*OIDCClaims, error) { + // 1. Parse token without validation to get kid + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, &OIDCClaims{}) + if err != nil { + return nil, fmt.Errorf("parsing token: %w", err) + } + + kid, ok := token.Header["kid"].(string) + if !ok { + return nil, errors.New("missing kid in token header") + } + + // 2. Get public key from JWKS (cached) + key, err := v.getKey(ctx, kid) + if err != nil { + return nil, fmt.Errorf("getting key: %w", err) + } + + // 3. Validate token with public key + claims := &OIDCClaims{} + token, err = jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return key, nil + }) + if err != nil { + return nil, fmt.Errorf("validating token: %w", err) + } + + if !token.Valid { + return nil, errors.New("invalid token") + } + + // 4. Validate issuer and audience + if claims.Issuer != v.issuerURL { + return nil, fmt.Errorf("invalid issuer: got %s, want %s", claims.Issuer, v.issuerURL) + } + + if !v.verifyAudience(claims) { + return nil, errors.New("invalid audience") + } + + return claims, nil +} + +// verifyAudience checks if the clientID is in the token's audience or authorized party (azp) +// Keycloak typically puts the client ID in azp rather than aud for access tokens +func (v *OIDCValidator) verifyAudience(claims *OIDCClaims) bool { + // Check azp claim (Keycloak's authorized party) + if claims.AuthorizedParty == v.clientID { + return true + } + // Check aud claim as fallback + for _, aud := range claims.Audience { + if aud == v.clientID { + return true + } + } + return false +} + +// getKey returns the RSA public key for the given kid, fetching from JWKS if needed +func (v *OIDCValidator) getKey(ctx context.Context, kid string) (*rsa.PublicKey, error) { + v.mu.RLock() + if key, ok := v.keys[kid]; ok && time.Now().Before(v.expiresAt) { + v.mu.RUnlock() + return key, nil + } + v.mu.RUnlock() + + // Fetch JWKS + return v.fetchJWKS(ctx, kid) +} + +// fetchJWKS fetches the JWKS from the issuer and caches the keys +func (v *OIDCValidator) fetchJWKS(ctx context.Context, kid string) (*rsa.PublicKey, error) { + v.mu.Lock() + defer v.mu.Unlock() + + // Double-check after acquiring write lock + if key, ok := v.keys[kid]; ok && time.Now().Before(v.expiresAt) { + return key, nil + } + + req, err := http.NewRequestWithContext(ctx, "GET", v.jwksURL, nil) + if err != nil { + return nil, fmt.Errorf("creating JWKS request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching JWKS: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("JWKS request failed: %s", resp.Status) + } + + var jwks struct { + Keys []struct { + Kid string `json:"kid"` + Kty string `json:"kty"` + Alg string `json:"alg"` + N string `json:"n"` + E string `json:"e"` + } `json:"keys"` + } + + if err := json.NewDecoder(resp.Body).Decode(&jwks); err != nil { + return nil, fmt.Errorf("decoding JWKS: %w", err) + } + + // Parse and cache all keys + v.keys = make(map[string]*rsa.PublicKey) + for _, k := range jwks.Keys { + if k.Kty != "RSA" { + continue + } + key, err := parseRSAPublicKey(k.N, k.E) + if err != nil { + continue + } + v.keys[k.Kid] = key + } + + // Cache for 1 hour + v.expiresAt = time.Now().Add(time.Hour) + + key, ok := v.keys[kid] + if !ok { + return nil, fmt.Errorf("key %s not found in JWKS", kid) + } + return key, nil +} + +// parseRSAPublicKey parses RSA public key from JWKS n and e values +func parseRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) { + nBytes, err := base64.RawURLEncoding.DecodeString(nStr) + if err != nil { + return nil, fmt.Errorf("decoding n: %w", err) + } + eBytes, err := base64.RawURLEncoding.DecodeString(eStr) + if err != nil { + return nil, fmt.Errorf("decoding e: %w", err) + } + + n := new(big.Int).SetBytes(nBytes) + e := int(new(big.Int).SetBytes(eBytes).Int64()) + + return &rsa.PublicKey{N: n, E: e}, nil +} diff --git a/auth/oidc_test.go b/auth/oidc_test.go new file mode 100644 index 000000000..2289bb010 --- /dev/null +++ b/auth/oidc_test.go @@ -0,0 +1,256 @@ +package auth_test + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/OpenSlides/openslides-go/auth" + "github.com/OpenSlides/openslides-go/auth/authtest" + "github.com/golang-jwt/jwt/v4" +) + +// TestOIDCValidator tests OIDC token validation via mock JWKS +func TestOIDCValidator(t *testing.T) { + // Generate test RSA key pair + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %v", err) + } + + // Start mock JWKS server + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + jwks := map[string]interface{}{ + "keys": []map[string]string{{ + "kid": "test-key-id", + "kty": "RSA", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(privateKey.E)).Bytes()), + }}, + } + json.NewEncoder(w).Encode(jwks) + })) + defer jwksServer.Close() + + issuerURL := jwksServer.URL + clientID := "test-client" + + validator := auth.NewOIDCValidator(issuerURL, issuerURL, clientID) + + t.Run("ValidToken", func(t *testing.T) { + token := createTestOIDCToken(t, privateKey, issuerURL, clientID, "keycloak-user-123") + claims, err := validator.ValidateToken(context.Background(), token) + if err != nil { + t.Fatalf("ValidateToken failed: %v", err) + } + if claims.KeycloakID != "keycloak-user-123" { + t.Errorf("Expected KeycloakID 'keycloak-user-123', got '%s'", claims.KeycloakID) + } + if claims.Username != "testuser" { + t.Errorf("Expected Username 'testuser', got '%s'", claims.Username) + } + if claims.Email != "test@example.com" { + t.Errorf("Expected Email 'test@example.com', got '%s'", claims.Email) + } + }) + + t.Run("ExpiredToken", func(t *testing.T) { + token := createExpiredOIDCToken(t, privateKey, issuerURL, clientID) + _, err := validator.ValidateToken(context.Background(), token) + if err == nil { + t.Error("Expected error for expired token") + } + }) + + t.Run("WrongIssuer", func(t *testing.T) { + token := createTestOIDCToken(t, privateKey, "https://wrong-issuer", clientID, "user") + _, err := validator.ValidateToken(context.Background(), token) + if err == nil { + t.Error("Expected error for wrong issuer") + } + }) + + t.Run("WrongAudience", func(t *testing.T) { + token := createTestOIDCToken(t, privateKey, issuerURL, "wrong-client", "user") + _, err := validator.ValidateToken(context.Background(), token) + if err == nil { + t.Error("Expected error for wrong audience") + } + }) + + t.Run("InvalidSignature", func(t *testing.T) { + // Create token signed with different key + otherKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate other RSA key: %v", err) + } + token := createTestOIDCToken(t, otherKey, issuerURL, clientID, "user") + _, err = validator.ValidateToken(context.Background(), token) + if err == nil { + t.Error("Expected error for invalid signature") + } + }) + + t.Run("MissingKid", func(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuerURL, + "aud": clientID, + "sub": "user", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + // Don't set kid in header + signed, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("Failed to sign token: %v", err) + } + _, err = validator.ValidateToken(context.Background(), signed) + if err == nil { + t.Error("Expected error for missing kid") + } + }) +} + +// TestOIDCValidatorKeyCache tests that JWKS keys are properly cached +func TestOIDCValidatorKeyCache(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Failed to generate RSA key: %v", err) + } + + requestCount := 0 + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + jwks := map[string]interface{}{ + "keys": []map[string]string{{ + "kid": "test-key-id", + "kty": "RSA", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(privateKey.E)).Bytes()), + }}, + } + json.NewEncoder(w).Encode(jwks) + })) + defer jwksServer.Close() + + validator := auth.NewOIDCValidator(jwksServer.URL, jwksServer.URL, "test-client") + + // Validate multiple tokens - should only fetch JWKS once + for i := 0; i < 5; i++ { + token := createTestOIDCToken(t, privateKey, jwksServer.URL, "test-client", "user") + _, err := validator.ValidateToken(context.Background(), token) + if err != nil { + t.Fatalf("ValidateToken failed: %v", err) + } + } + + if requestCount != 1 { + t.Errorf("Expected 1 JWKS request (cached), got %d", requestCount) + } +} + +// TestOIDCTestServer tests the authtest helper +func TestOIDCTestServer(t *testing.T) { + ots, err := authtest.NewOIDCTestServer("test-client") + if err != nil { + t.Fatalf("Failed to create OIDCTestServer: %v", err) + } + defer ots.Close() + + validator := auth.NewOIDCValidator(ots.IssuerURL, ots.IssuerURL, ots.ClientID) + + t.Run("CreateToken", func(t *testing.T) { + token, err := ots.CreateToken("user-123") + if err != nil { + t.Fatalf("Failed to create token: %v", err) + } + + claims, err := validator.ValidateToken(context.Background(), token) + if err != nil { + t.Fatalf("ValidateToken failed: %v", err) + } + if claims.KeycloakID != "user-123" { + t.Errorf("Expected KeycloakID 'user-123', got '%s'", claims.KeycloakID) + } + }) + + t.Run("CreateExpiredToken", func(t *testing.T) { + token, err := ots.CreateExpiredToken("user-123") + if err != nil { + t.Fatalf("Failed to create expired token: %v", err) + } + + _, err = validator.ValidateToken(context.Background(), token) + if err == nil { + t.Error("Expected error for expired token") + } + }) + + t.Run("CreateTokenWithClaims", func(t *testing.T) { + token, err := ots.CreateTokenWithClaims(jwt.MapClaims{ + "sub": "custom-user", + "email": "custom@example.com", + }) + if err != nil { + t.Fatalf("Failed to create token with claims: %v", err) + } + + claims, err := validator.ValidateToken(context.Background(), token) + if err != nil { + t.Fatalf("ValidateToken failed: %v", err) + } + if claims.KeycloakID != "custom-user" { + t.Errorf("Expected KeycloakID 'custom-user', got '%s'", claims.KeycloakID) + } + if claims.Email != "custom@example.com" { + t.Errorf("Expected Email 'custom@example.com', got '%s'", claims.Email) + } + }) +} + +// Helper functions + +func createTestOIDCToken(t *testing.T, key *rsa.PrivateKey, issuer, aud, sub string) string { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, + "aud": aud, + "sub": sub, + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + "preferred_username": "testuser", + "email": "test@example.com", + }) + token.Header["kid"] = "test-key-id" + + signed, err := token.SignedString(key) + if err != nil { + t.Fatalf("Failed to sign token: %v", err) + } + return signed +} + +func createExpiredOIDCToken(t *testing.T, key *rsa.PrivateKey, issuer, aud string) string { + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, + "aud": aud, + "sub": "user", + "exp": time.Now().Add(-time.Hour).Unix(), // Expired + "iat": time.Now().Add(-2 * time.Hour).Unix(), + }) + token.Header["kid"] = "test-key-id" + + signed, err := token.SignedString(key) + if err != nil { + t.Fatalf("Failed to sign token: %v", err) + } + return signed +} diff --git a/auth/user_lookup.go b/auth/user_lookup.go new file mode 100644 index 000000000..b1d15c3cc --- /dev/null +++ b/auth/user_lookup.go @@ -0,0 +1,31 @@ +package auth + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// UserLookup provides user ID lookup by keycloak_id +type UserLookup struct { + pool *pgxpool.Pool +} + +// NewUserLookup creates a UserLookup with the given connection pool +func NewUserLookup(pool *pgxpool.Pool) *UserLookup { + return &UserLookup{pool: pool} +} + +// GetUserIDByKeycloakID returns the OpenSlides user ID for the given keycloak_id +func (l *UserLookup) GetUserIDByKeycloakID(ctx context.Context, keycloakID string) (int, error) { + var userID int + err := l.pool.QueryRow(ctx, + "SELECT id FROM user_t WHERE keycloak_id = $1 AND is_active = true", + keycloakID, + ).Scan(&userID) + if err != nil { + return 0, fmt.Errorf("user lookup by keycloak_id: %w", err) + } + return userID, nil +} diff --git a/datastore/pgtest/sql/schema_relational.sql b/datastore/pgtest/sql/schema_relational.sql index 24093a5e8..19fd6ee74 100644 --- a/datastore/pgtest/sql/schema_relational.sql +++ b/datastore/pgtest/sql/schema_relational.sql @@ -1392,6 +1392,7 @@ CREATE TABLE user_t ( username varchar(256) NOT NULL, member_number varchar(256), saml_id varchar(256) CONSTRAINT minlength_saml_id CHECK (char_length(saml_id) >= 1), + keycloak_id varchar(256) CONSTRAINT minlength_keycloak_id CHECK (char_length(keycloak_id) >= 1), pronoun varchar(32), title varchar(256), first_name varchar(256), @@ -1416,6 +1417,7 @@ CREATE TABLE user_t ( comment on column user_t.saml_id is 'unique-key from IdP for SAML login'; +comment on column user_t.keycloak_id is 'unique-key from Keycloak for OIDC login'; comment on column user_t.organization_management_level is 'Hierarchical permission level for the whole organization.'; diff --git a/meta b/meta index 11fb92a1f..761c67d50 160000 --- a/meta +++ b/meta @@ -1 +1 @@ -Subproject commit 11fb92a1f9722f479e419395a7aacab075a347ab +Subproject commit 761c67d501bb98db25fdf4ff275f17614258841c