@@ -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
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.
119161func (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
274339func (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+ }
0 commit comments