Skip to content

Commit b89eb6a

Browse files
boehlkeclaude
andcommitted
feat: add OIDC authentication support for Go services
- Add OIDCValidator with JWKS discovery and token validation - Add UserLookup to resolve keycloak_id to OpenSlides user ID via PostgreSQL - Extend auth.New() to accept optional *pgxpool.Pool for OIDC user resolution - Support OIDC environment variables (OIDC_ENABLED, OIDC_ISSUER_URL, etc.) - Include unit tests for OIDC validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 09fa1a8 commit b89eb6a

7 files changed

Lines changed: 750 additions & 60 deletions

File tree

auth/auth.go

Lines changed: 92 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/OpenSlides/openslides-go/environment"
1717
"github.com/OpenSlides/openslides-go/oserror"
1818
"github.com/golang-jwt/jwt/v4"
19+
"github.com/jackc/pgx/v5/pgxpool"
1920
"github.com/ostcar/topic"
2021
)
2122

@@ -33,6 +34,12 @@ var (
3334

3435
envAuthTokenFile = environment.NewVariable("AUTH_TOKEN_KEY_FILE", "/run/secrets/auth_token_key", "Key to sign the JWT auth tocken.")
3536
envAuthCookieFile = environment.NewVariable("AUTH_COOKIE_KEY_FILE", "/run/secrets/auth_cookie_key", "Key to sign the JWT auth cookie.")
37+
38+
// OIDC environment variables
39+
envOIDCEnabled = environment.NewVariable("OIDC_ENABLED", "false", "Enable OIDC authentication.")
40+
envOIDCIssuerURL = environment.NewVariable("OIDC_ISSUER_URL", "", "Keycloak Realm URL (external) for issuer validation.")
41+
envOIDCInternalIssuerURL = environment.NewVariable("OIDC_INTERNAL_ISSUER_URL", "", "Keycloak Realm URL (internal) for JWKS discovery. Defaults to OIDC_ISSUER_URL.")
42+
envOIDCClientID = environment.NewVariable("OIDC_CLIENT_ID", "", "Expected audience in OIDC token.")
3643
)
3744

3845
// pruneTime defines how long a topic id will be valid. This should be higher
@@ -65,13 +72,21 @@ type Auth struct {
6572

6673
tokenKey string
6774
cookieKey string
75+
76+
// OIDC fields
77+
oidcEnabled bool
78+
oidcValidator *OIDCValidator
79+
userLookup *UserLookup
6880
}
6981

7082
// New initializes the Auth object.
7183
//
72-
// Returns the initialized Auth objectand a function to be called in the
84+
// Returns the initialized Auth object and a function to be called in the
7385
// background.
74-
func New(lookup environment.Environmenter, messageBus LogoutEventer) (*Auth, func(context.Context, func(error)), error) {
86+
//
87+
// The pool parameter is optional and only needed for OIDC mode to lookup users
88+
// by keycloak_id.
89+
func New(lookup environment.Environmenter, messageBus LogoutEventer, pool ...*pgxpool.Pool) (*Auth, func(context.Context, func(error)), error) {
7590
url := fmt.Sprintf(
7691
"%s://%s:%s",
7792
envAuthProtocol.Value(lookup),
@@ -91,12 +106,40 @@ func New(lookup environment.Environmenter, messageBus LogoutEventer) (*Auth, fun
91106
return nil, nil, fmt.Errorf("reading cookie token: %w", err)
92107
}
93108

109+
// OIDC configuration
110+
oidcEnabled, _ := strconv.ParseBool(envOIDCEnabled.Value(lookup))
111+
112+
var oidcValidator *OIDCValidator
113+
var userLookup *UserLookup
114+
115+
if oidcEnabled {
116+
issuerURL := envOIDCIssuerURL.Value(lookup)
117+
internalIssuerURL := envOIDCInternalIssuerURL.Value(lookup)
118+
clientID := envOIDCClientID.Value(lookup)
119+
if issuerURL == "" || clientID == "" {
120+
return nil, nil, fmt.Errorf("OIDC enabled but OIDC_ISSUER_URL or OIDC_CLIENT_ID not set")
121+
}
122+
// Use internal URL for JWKS if provided, otherwise use issuer URL
123+
if internalIssuerURL == "" {
124+
internalIssuerURL = issuerURL
125+
}
126+
oidcValidator = NewOIDCValidator(issuerURL, internalIssuerURL, clientID)
127+
128+
if len(pool) == 0 || pool[0] == nil {
129+
return nil, nil, fmt.Errorf("OIDC enabled but no database pool provided")
130+
}
131+
userLookup = NewUserLookup(pool[0])
132+
}
133+
94134
a := &Auth{
95135
fake: fake,
96136
logedoutSessions: topic.New[string](),
97137
authServiceURL: url,
98138
tokenKey: authToken,
99139
cookieKey: cookieToken,
140+
oidcEnabled: oidcEnabled,
141+
oidcValidator: oidcValidator,
142+
userLookup: userLookup,
100143
}
101144

102145
// Make sure the topic is not empty
@@ -115,7 +158,6 @@ func New(lookup environment.Environmenter, messageBus LogoutEventer) (*Auth, fun
115158
}
116159

117160
// Authenticate uses the headers from the given request to get the user id. The
118-
// returned context will be cancled, if the session is revoked.
119161
func (a *Auth) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) {
120162
if a.fake {
121163
return r.Context(), nil
@@ -134,13 +176,13 @@ func (a *Auth) Authenticate(w http.ResponseWriter, r *http.Request) (context.Con
134176

135177
cid, sessionIDs := a.logedoutSessions.ReceiveAll()
136178
if slices.Contains(sessionIDs, p.SessionID) {
137-
return nil, &authError{"invalid session", nil}
179+
return nil, &authError{msg: "invalid session", status: http.StatusUnauthorized}
138180
}
139181

140-
ctx, cancelCtx := context.WithCancel(a.AuthenticatedContext(ctx, p.UserID))
182+
ctx, cancelCtx := context.WithCancelCause(a.AuthenticatedContext(ctx, p.UserID))
141183

142184
go func() {
143-
defer cancelCtx()
185+
defer cancelCtx(nil)
144186

145187
var sessionIDs []string
146188
var err error
@@ -151,6 +193,8 @@ func (a *Auth) Authenticate(w http.ResponseWriter, r *http.Request) (context.Con
151193
}
152194

153195
if slices.Contains(sessionIDs, p.SessionID) {
196+
// Cancel with LogoutError to signal server-initiated logout
197+
cancelCtx(LogoutError{SessionID: p.SessionID})
154198
return
155199
}
156200
}
@@ -223,26 +267,47 @@ func (a *Auth) pruneOldData(ctx context.Context) {
223267

224268
// loadToken loads and validates the token. If the token is expires, it tries
225269
// to renews it and writes the new token to the responsewriter.
226-
func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, payload jwt.Claims) error {
270+
func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, p *payload) error {
227271
header := r.Header.Get(authHeader)
228272
cookie, err := r.Cookie(cookieName)
229273
if err != nil && err != http.ErrNoCookie {
230274
return fmt.Errorf("reading cookie: %w", err)
231275
}
232276

233277
encodedToken := strings.TrimPrefix(header, "bearer ")
278+
hasBearerToken := header != encodedToken
234279

235-
if cookie == nil && header == encodedToken {
236-
// No token and no auth cookie. Handle the request as public access requst.
280+
// No token and no auth cookie - public access
281+
if cookie == nil && !hasBearerToken {
237282
return nil
238283
}
239284

240-
if cookie == nil && header != encodedToken {
241-
return authError{"Can not find auth cookie", nil}
285+
// Try OIDC validation if enabled and we have a bearer token (without cookie)
286+
if a.oidcEnabled && a.oidcValidator != nil && hasBearerToken && cookie == nil {
287+
claims, err := a.oidcValidator.ValidateToken(r.Context(), encodedToken)
288+
if err == nil {
289+
// OIDC token valid - lookup user ID by keycloak_id
290+
userID, err := a.userLookup.GetUserIDByKeycloakID(r.Context(), claims.KeycloakID)
291+
if err != nil {
292+
return authError{msg: fmt.Sprintf("user not found: %s", claims.KeycloakID), wrapped: err}
293+
}
294+
295+
// Set user ID in payload
296+
p.UserID = userID
297+
p.SessionID = claims.SessionID
298+
return nil
299+
}
300+
// OIDC validation failed - return error (no cookie means no legacy fallback)
301+
return authError{msg: "Invalid OIDC token", wrapped: err}
302+
}
303+
304+
// Legacy validation requires both cookie and token
305+
if cookie == nil && hasBearerToken {
306+
return authError{msg: "Can not find auth cookie"}
242307
}
243308

244-
if cookie != nil && header == encodedToken {
245-
return authError{"Can not find auth token", nil}
309+
if cookie != nil && !hasBearerToken {
310+
return authError{msg: "Can not find auth token"}
246311
}
247312

248313
encodedCookie := strings.TrimPrefix(cookie.Value, "bearer%20")
@@ -253,12 +318,12 @@ func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, payload jwt.Cla
253318
if err != nil {
254319
var invalid *jwt.ValidationError
255320
if errors.As(err, &invalid) {
256-
return authError{"Invalid auth token", err}
321+
return authError{msg: "Invalid auth token", wrapped: err}
257322
}
258323
return fmt.Errorf("validating auth cookie: %w", err)
259324
}
260325

261-
_, err = jwt.ParseWithClaims(encodedToken, payload, func(token *jwt.Token) (interface{}, error) {
326+
_, err = jwt.ParseWithClaims(encodedToken, p, func(token *jwt.Token) (interface{}, error) {
262327
return []byte(a.tokenKey), nil
263328
})
264329
if err != nil {
@@ -273,7 +338,7 @@ func (a *Auth) loadToken(w http.ResponseWriter, r *http.Request, payload jwt.Cla
273338

274339
func (a *Auth) handleInvalidToken(ctx context.Context, invalid *jwt.ValidationError, w http.ResponseWriter, encodedToken, encodedCookie string) error {
275340
if !tokenExpired(invalid.Errors) {
276-
return authError{"Invalid auth token", invalid}
341+
return authError{msg: "Invalid auth token", wrapped: invalid}
277342
}
278343

279344
token, err := a.refreshToken(ctx, encodedToken, encodedCookie)
@@ -324,7 +389,7 @@ func (a *Auth) refreshToken(ctx context.Context, token, cookie string) (string,
324389
if rPayload.Message == "" {
325390
rPayload.Message = "Can not refresh token"
326391
}
327-
return "", authError{rPayload.Message, nil}
392+
return "", authError{msg: rPayload.Message}
328393

329394
}
330395

@@ -342,3 +407,13 @@ type payload struct {
342407
UserID int `json:"userId"`
343408
SessionID string `json:"sessionId"`
344409
}
410+
411+
// LogoutError indicates that a session was terminated due to logout.
412+
// This error is used as the cause when cancelling a context due to backchannel logout.
413+
type LogoutError struct {
414+
SessionID string
415+
}
416+
417+
func (e LogoutError) Error() string {
418+
return "session logged out"
419+
}

auth/authtest/oidc.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package authtest
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/rsa"
6+
"encoding/base64"
7+
"encoding/json"
8+
"math/big"
9+
"net/http"
10+
"net/http/httptest"
11+
"time"
12+
13+
"github.com/golang-jwt/jwt/v4"
14+
)
15+
16+
// OIDCTestServer provides a mock Keycloak JWKS endpoint for testing
17+
type OIDCTestServer struct {
18+
Server *httptest.Server
19+
PrivateKey *rsa.PrivateKey
20+
IssuerURL string
21+
ClientID string
22+
}
23+
24+
// NewOIDCTestServer creates a test server with mock JWKS endpoint
25+
func NewOIDCTestServer(clientID string) (*OIDCTestServer, error) {
26+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
27+
if err != nil {
28+
return nil, err
29+
}
30+
31+
ots := &OIDCTestServer{
32+
PrivateKey: privateKey,
33+
ClientID: clientID,
34+
}
35+
36+
mux := http.NewServeMux()
37+
mux.HandleFunc("/protocol/openid-connect/certs", ots.handleJWKS)
38+
39+
ots.Server = httptest.NewServer(mux)
40+
ots.IssuerURL = ots.Server.URL
41+
42+
return ots, nil
43+
}
44+
45+
func (ots *OIDCTestServer) handleJWKS(w http.ResponseWriter, r *http.Request) {
46+
jwks := map[string]interface{}{
47+
"keys": []map[string]string{{
48+
"kid": "test-key-id",
49+
"kty": "RSA",
50+
"alg": "RS256",
51+
"n": base64.RawURLEncoding.EncodeToString(ots.PrivateKey.N.Bytes()),
52+
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(ots.PrivateKey.E)).Bytes()),
53+
}},
54+
}
55+
json.NewEncoder(w).Encode(jwks)
56+
}
57+
58+
// CreateToken creates a valid OIDC token for testing
59+
func (ots *OIDCTestServer) CreateToken(keycloakID string) (string, error) {
60+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
61+
"iss": ots.IssuerURL,
62+
"aud": ots.ClientID,
63+
"sub": keycloakID,
64+
"exp": time.Now().Add(time.Hour).Unix(),
65+
"iat": time.Now().Unix(),
66+
"preferred_username": "testuser",
67+
"email": "test@example.com",
68+
})
69+
token.Header["kid"] = "test-key-id"
70+
return token.SignedString(ots.PrivateKey)
71+
}
72+
73+
// CreateTokenWithClaims creates an OIDC token with custom claims for testing
74+
func (ots *OIDCTestServer) CreateTokenWithClaims(claims jwt.MapClaims) (string, error) {
75+
// Set defaults if not provided
76+
if _, ok := claims["iss"]; !ok {
77+
claims["iss"] = ots.IssuerURL
78+
}
79+
if _, ok := claims["aud"]; !ok {
80+
claims["aud"] = ots.ClientID
81+
}
82+
if _, ok := claims["exp"]; !ok {
83+
claims["exp"] = time.Now().Add(time.Hour).Unix()
84+
}
85+
if _, ok := claims["iat"]; !ok {
86+
claims["iat"] = time.Now().Unix()
87+
}
88+
89+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
90+
token.Header["kid"] = "test-key-id"
91+
return token.SignedString(ots.PrivateKey)
92+
}
93+
94+
// CreateExpiredToken creates an expired OIDC token for testing
95+
func (ots *OIDCTestServer) CreateExpiredToken(keycloakID string) (string, error) {
96+
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
97+
"iss": ots.IssuerURL,
98+
"aud": ots.ClientID,
99+
"sub": keycloakID,
100+
"exp": time.Now().Add(-time.Hour).Unix(), // Expired
101+
"iat": time.Now().Add(-2 * time.Hour).Unix(),
102+
"preferred_username": "testuser",
103+
"email": "test@example.com",
104+
})
105+
token.Header["kid"] = "test-key-id"
106+
return token.SignedString(ots.PrivateKey)
107+
}
108+
109+
// Close shuts down the test server
110+
func (ots *OIDCTestServer) Close() {
111+
ots.Server.Close()
112+
}

auth/error.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package auth
33
type authError struct {
44
msg string
55
wrapped error
6+
status int
67
}
78

89
func (authError) Type() string {
@@ -18,5 +19,8 @@ func (a authError) Unwrap() error {
1819
}
1920

2021
func (a authError) StatusCode() int {
22+
if a.status != 0 {
23+
return a.status
24+
}
2125
return 403
2226
}

0 commit comments

Comments
 (0)