From 877c9344fce30663304d39fc7bd33d23ecfc279b Mon Sep 17 00:00:00 2001 From: Simon Eisenmann Date: Mon, 23 Mar 2026 15:33:13 +0100 Subject: [PATCH] Add signed JWT auto sign-in flow (LibreGraph.SignedLoginOK) When --allow-client-signed-logins is enabled, trusted apps can sign in users without an interactive password challenge by sending an OIDC authorization request that includes the LibreGraph.SignedLoginOK scope together with a signed request object carrying a preferred_username claim. The signed login path is built directly into IdentifierIdentityManager: - checkAndRecordJTI: in-memory JTI replay prevention (10 min window) - authenticateSignedLogin: validates the signed JWT, looks up the user by preferred_username, writes a logon cookie so subsequent silent-renew and re-auth requests succeed via the normal cookie path without needing a new signed JWT each time - authorizeSignedLogin: verifies client identity against the signed request object and approves scopes via the client's trusted_scopes list WriteLogonCookie is introduced on Identifier to write the cookie mid-flow without firing onSetLogonCallbacks (which would produce a spurious browser-state cookie header in the same response). SetUserToLogonCookie is refactored to delegate to it. --- bootstrap/backends/ldap/ldap.go | 7 +- bootstrap/backends/libregraph/libregraph.go | 7 +- bootstrap/bootstrap.go | 5 + bootstrap/settings.go | 1 + cmd/licod/serve.go | 1 + config/config.go | 1 + identifier/identifier.go | 27 +-- identifier/user.go | 6 + identity/config.go | 3 + identity/managers/identifier.go | 185 +++++++++++++++++++- oidc/provider/handlers.go | 4 +- scopes.go | 3 + 12 files changed, 229 insertions(+), 21 deletions(-) diff --git a/bootstrap/backends/ldap/ldap.go b/bootstrap/backends/ldap/ldap.go index bcc37a55..dbd76213 100644 --- a/bootstrap/backends/ldap/ldap.go +++ b/bootstrap/backends/ldap/ldap.go @@ -160,13 +160,18 @@ func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) { return nil, fmt.Errorf("invalid --encryption-secret parameter value for identifier: %v", err) } + // Expose the identifier in the managers registry so other managers (e.g. + // signedlogin) can access it without creating a second instance. + bs.Managers().Set("identifier", activeIdentifier) + identityManagerConfig := &identity.Config{ SignInFormURI: fullSignInFormURL, SignedOutURI: fullSignedOutEndpointURL, Logger: logger, - ScopesSupported: config.Config.AllowedScopes, + ScopesSupported: config.Config.AllowedScopes, + AllowSignedLogin: config.Config.AllowClientSignedLogins, } identifierIdentityManager := managers.NewIdentifierIdentityManager(identityManagerConfig, activeIdentifier) diff --git a/bootstrap/backends/libregraph/libregraph.go b/bootstrap/backends/libregraph/libregraph.go index 25367670..04c27218 100644 --- a/bootstrap/backends/libregraph/libregraph.go +++ b/bootstrap/backends/libregraph/libregraph.go @@ -143,13 +143,18 @@ func NewIdentityManager(bs bootstrap.Bootstrap) (identity.Manager, error) { return nil, fmt.Errorf("invalid --encryption-secret parameter value for identifier: %v", err) } + // Expose the identifier in the managers registry so other managers (e.g. + // signedlogin) can access it without creating a second instance. + bs.Managers().Set("identifier", activeIdentifier) + identityManagerConfig := &identity.Config{ SignInFormURI: fullSignInFormURL, SignedOutURI: fullSignedOutEndpointURL, Logger: logger, - ScopesSupported: config.Config.AllowedScopes, + ScopesSupported: config.Config.AllowedScopes, + AllowSignedLogin: config.Config.AllowClientSignedLogins, } identifierIdentityManager := managers.NewIdentifierIdentityManager(identityManagerConfig, activeIdentifier) diff --git a/bootstrap/bootstrap.go b/bootstrap/bootstrap.go index bbd74f70..14bd2b3c 100644 --- a/bootstrap/bootstrap.go +++ b/bootstrap/bootstrap.go @@ -206,6 +206,11 @@ func (bs *bootstrap) initialize(settings *Settings) error { logger.Infoln("client controlled guests are enabled") } + bs.config.Config.AllowClientSignedLogins = settings.AllowClientSignedLogins + if bs.config.Config.AllowClientSignedLogins { + logger.Infoln("client controlled signed logins are enabled") + } + bs.config.Config.AllowDynamicClientRegistration = settings.AllowDynamicClientRegistration if bs.config.Config.AllowDynamicClientRegistration { logger.Infoln("dynamic client registration is enabled") diff --git a/bootstrap/settings.go b/bootstrap/settings.go index 35720ef2..0a4b050a 100644 --- a/bootstrap/settings.go +++ b/bootstrap/settings.go @@ -35,6 +35,7 @@ type Settings struct { TrustedProxy []string AllowScope []string AllowClientGuests bool + AllowClientSignedLogins bool AllowDynamicClientRegistration bool EncryptionSecretFile string Listen string diff --git a/cmd/licod/serve.go b/cmd/licod/serve.go index 4645acff..b3ee5690 100644 --- a/cmd/licod/serve.go +++ b/cmd/licod/serve.go @@ -98,6 +98,7 @@ func commandServe() *cobra.Command { serveCmd.Flags().StringArrayVar(&cfg.TrustedProxy, "trusted-proxy", nil, "Trusted proxy IP or IP network (can be used multiple times)") serveCmd.Flags().StringArrayVar(&cfg.AllowScope, "allow-scope", nil, "Allow OAuth 2 scope (can be used multiple times, if not set default scopes are allowed)") serveCmd.Flags().BoolVar(&cfg.AllowClientGuests, "allow-client-guests", false, "Allow sign in of client controlled guest users") + serveCmd.Flags().BoolVar(&cfg.AllowClientSignedLogins, "allow-client-signed-logins", false, "Allow sign in of client controlled signed login users") serveCmd.Flags().BoolVar(&cfg.AllowDynamicClientRegistration, "allow-dynamic-client-registration", false, "Allow dynamic OAuth2 client registration") serveCmd.Flags().Uint64Var(&cfg.AccessTokenDurationSeconds, "access-token-expiration", 60*10, "Expiration time of access tokens in seconds since generated") // 10 Minutes. serveCmd.Flags().Uint64Var(&cfg.IDTokenDurationSeconds, "id-token-expiration", 60*60, "Expiration time of id tokens in seconds since generated") // 1 Hour. diff --git a/config/config.go b/config/config.go index 4f5d7d36..5c803d61 100644 --- a/config/config.go +++ b/config/config.go @@ -38,5 +38,6 @@ type Config struct { AllowedScopes []string AllowClientGuests bool + AllowClientSignedLogins bool AllowDynamicClientRegistration bool } diff --git a/identifier/identifier.go b/identifier/identifier.go index b5029749..708d1156 100644 --- a/identifier/identifier.go +++ b/identifier/identifier.go @@ -269,27 +269,26 @@ func (i *Identifier) ErrorPage(rw http.ResponseWriter, code int, title string, m utils.WriteErrorPage(rw, code, title, message) } -// SetUserToLogonCookie serializes the provided user into an encrypted string -// and sets it as cookie on the provided http.ResponseWriter. -func (i *Identifier) SetUserToLogonCookie(ctx context.Context, rw http.ResponseWriter, user *IdentifiedUser) error { +// WriteLogonCookie serializes the provided user into an encrypted string and +// sets it as a logon cookie without firing any callbacks. Use this when the +// cookie must be written mid-flow (e.g. inside an authorize handler) where +// the caller is responsible for updating browser state separately. +func (i *Identifier) WriteLogonCookie(rw http.ResponseWriter, user *IdentifiedUser) error { loggedOn, logonAt := user.LoggedOn() if !loggedOn { return fmt.Errorf("refused to set cookie for not logged on user") } - // Add standard claims. claims := jwt.Claims{ Issuer: user.BackendName(), Audience: audienceMarker, Subject: user.Subject(), IssuedAt: jwt.NewNumericDate(logonAt), } - // Add expiration, if set. if user.expiresAfter != nil { claims.Expiry = jwt.NewNumericDate(*user.expiresAfter) } - // Additional claims. userClaims := map[string]interface{}(user.Claims()) if sessionRef := user.SessionRef(); sessionRef != nil { userClaims[SessionIDClaim] = *sessionRef @@ -304,25 +303,27 @@ func (i *Identifier) SetUserToLogonCookie(ctx context.Context, rw http.ResponseW userClaims[LockedScopesClaim] = strings.Join(lockedScopes, " ") } - // Serialize and encrypt cookie value. serialized, err := jwt.Encrypted(i.encrypter).Claims(claims).Claims(userClaims).CompactSerialize() if err != nil { return err } - // Set cookie. - err = i.setLogonCookie(rw, serialized) - if err != nil { + return i.setLogonCookie(rw, serialized) +} + +// SetUserToLogonCookie serializes the provided user into an encrypted string, +// sets it as cookie on the provided http.ResponseWriter, and fires the +// onSetLogon callbacks. +func (i *Identifier) SetUserToLogonCookie(ctx context.Context, rw http.ResponseWriter, user *IdentifiedUser) error { + if err := i.WriteLogonCookie(rw, user); err != nil { return err } // Trigger callbacks. for _, f := range i.onSetLogonCallbacks { - err = f(ctx, rw, user) - if err != nil { + if err := f(ctx, rw, user); err != nil { return err } } - return nil } diff --git a/identifier/user.go b/identifier/user.go index 97db734c..fb3fdd81 100644 --- a/identifier/user.go +++ b/identifier/user.go @@ -146,6 +146,12 @@ func (u *IdentifiedUser) LoggedOn() (bool, time.Time) { return !u.logonAt.IsZero(), u.logonAt } +// SetLogonAt sets the logon timestamp, marking the user as logged on. Used by +// flows that authenticate without a password challenge (e.g. signed login). +func (u *IdentifiedUser) SetLogonAt(t time.Time) { + u.logonAt = t +} + // SessionRef returns the accociated users underlaying session reference. func (u *IdentifiedUser) SessionRef() *string { return u.sessionRef diff --git a/identity/config.go b/identity/config.go index 12fbca20..21ab87aa 100644 --- a/identity/config.go +++ b/identity/config.go @@ -30,5 +30,8 @@ type Config struct { ScopesSupported []string + // AllowSignedLogin enables the signed JWT auto sign-in flow. + AllowSignedLogin bool + Logger logrus.FieldLogger } diff --git a/identity/managers/identifier.go b/identity/managers/identifier.go index 156d2743..b75a39fd 100644 --- a/identity/managers/identifier.go +++ b/identity/managers/identifier.go @@ -23,12 +23,16 @@ import ( "net/http" "net/url" "strings" + "sync" + "time" + "github.com/golang-jwt/jwt/v5" "github.com/gorilla/mux" "github.com/libregraph/oidc-go" "github.com/longsleep/rndm" "github.com/sirupsen/logrus" + konnect "github.com/libregraph/lico" "github.com/libregraph/lico/identifier" "github.com/libregraph/lico/identity" "github.com/libregraph/lico/identity/clients" @@ -47,9 +51,17 @@ type IdentifierIdentityManager struct { scopesSupported []string claimsSupported []string - identifier *identifier.Identifier - clients *clients.Registry - logger logrus.FieldLogger + identifier *identifier.Identifier + clients *clients.Registry + guestManager identity.Manager + logger logrus.FieldLogger + + allowSignedLogin bool + + // JTI replay prevention store for the signed login flow. + jtiMu sync.Mutex + jtiStore map[string]time.Time + jtiMaxAge time.Duration } type identifierUser struct { @@ -108,8 +120,11 @@ func NewIdentifierIdentityManager(c *identity.Config, i *identifier.Identifier) oidc.EmailVerifiedClaim, }, - identifier: i, - logger: c.Logger, + identifier: i, + logger: c.Logger, + allowSignedLogin: c.AllowSignedLogin, + jtiStore: make(map[string]time.Time), + jtiMaxAge: 10 * time.Minute, } return im @@ -119,6 +134,11 @@ func NewIdentifierIdentityManager(c *identity.Config, i *identifier.Identifier) func (im *IdentifierIdentityManager) RegisterManagers(mgrs *managers.Managers) error { im.clients = mgrs.Must("clients").(*clients.Registry) + // Wire guest manager as fallback if available. + if guestManager, ok := mgrs.Get("guest"); ok && guestManager != nil { + im.guestManager = guestManager.(identity.Manager) + } + return im.identifier.RegisterManagers(mgrs) } @@ -132,7 +152,14 @@ func (im *IdentifierIdentityManager) Authenticate(ctx context.Context, rw http.R return nil, ar.NewError(authenticationErrorID, req.Form.Get("error_description")) } + // When signed login is enabled and a signed JWT request is present, handle + // the signed login flow directly and bypass any existing session cookie. + if im.allowSignedLogin && ar.Scopes[konnect.ScopeSignedLoginOK] && ar.Request != nil { + return im.authenticateSignedLogin(ctx, rw, req, ar) + } + u, _ := im.identifier.GetUserFromLogonCookie(ctx, req, ar.MaxAge, true) + if u != nil { // TODO(longsleep): Add other user meta data. user = asIdentifierUser(u) @@ -278,6 +305,11 @@ func (im *IdentifierIdentityManager) Authenticate(ctx context.Context, rw http.R // Authorize implements the identity.Manager interface. func (im *IdentifierIdentityManager) Authorize(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) { + // Route signed login authorizations through their own path. + if im.allowSignedLogin && ar.Scopes[konnect.ScopeSignedLoginOK] && ar.Request != nil { + return im.authorizeSignedLogin(ctx, rw, req, ar, auth) + } + promptConsent := false var approvedScopes map[string]bool @@ -407,6 +439,149 @@ func (im *IdentifierIdentityManager) Authorize(ctx context.Context, rw http.Resp return auth, nil } +// checkAndRecordJTI checks the JTI claim for replay and records it if new. +func (im *IdentifierIdentityManager) checkAndRecordJTI(ar *payload.AuthenticationRequest) error { + roc, ok := ar.Request.Claims.(*payload.RequestObjectClaims) + if !ok { + return nil + } + + jti := roc.ID + if jti == "" { + return fmt.Errorf("IdentifierIdentityManager: missing or invalid jti claim") + } + + now := time.Now() + exp := now.Add(im.jtiMaxAge) + + // Use the JWT expiry as TTL if shorter than the max age. + if expTime, err := roc.GetExpirationTime(); err == nil && expTime != nil { + if expTime.Time.Before(exp) { + exp = expTime.Time + } + } + + im.jtiMu.Lock() + defer im.jtiMu.Unlock() + + // Purge expired entries. + for k, v := range im.jtiStore { + if now.After(v) { + delete(im.jtiStore, k) + } + } + + if _, exists := im.jtiStore[jti]; exists { + return fmt.Errorf("IdentifierIdentityManager: replayed jti") + } + + im.jtiStore[jti] = exp + return nil +} + +// authenticateSignedLogin handles the signed JWT auto sign-in flow. +func (im *IdentifierIdentityManager) authenticateSignedLogin(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest) (identity.AuthRecord, error) { + if ar.Request.Method == jwt.SigningMethodNone { + return nil, ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "IdentifierIdentityManager: request object must be signed") + } + + roc, ok := ar.Request.Claims.(*payload.RequestObjectClaims) + if !ok || roc.Claims == nil || ar.Claims == nil || ar.Claims.IDToken == nil { + return nil, ar.NewError(oidc.ErrorCodeOAuth2InvalidRequest, "IdentifierIdentityManager: missing claims in request object") + } + + // Extract the login hint from the signed claims. + loginHint, ok := ar.Claims.IDToken.GetStringValue(oidc.PreferredUsernameClaim) + if !ok || loginHint == "" { + return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "IdentifierIdentityManager: missing preferred_username claim") + } + + // JTI replay prevention. + if err := im.checkAndRecordJTI(ar); err != nil { + return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, err.Error()) + } + + // Look up user from the scoped backend. + u, err := im.identifier.GetUserFromID(ctx, loginHint, nil, ar.Scopes) + if err != nil { + im.logger.WithError(err).Errorln("IdentifierIdentityManager: signed login backend error") + return nil, ar.NewError(oidc.ErrorCodeOAuth2ServerError, "IdentifierIdentityManager: backend error") + } + if u == nil { + return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, "IdentifierIdentityManager: user not found") + } + + user := asIdentifierUser(u) + + if err := ar.Verify(user.Subject()); err != nil { + return nil, err + } + + // Set logon time and write a session cookie so that subsequent + // authorization requests (silent renew, re-auth) succeed via the normal + // cookie path without requiring a new signed JWT each time. + authTime := time.Now() + u.SetLogonAt(authTime) + if cookieErr := im.identifier.WriteLogonCookie(rw, u); cookieErr != nil { + im.logger.WithError(cookieErr).Warnln("IdentifierIdentityManager: failed to set logon cookie") + } + + auth := identity.NewAuthRecord(im, user.Subject(), nil, nil, nil) + auth.SetUser(user) + auth.SetAuthTime(authTime) + return auth, nil +} + +// authorizeSignedLogin handles scope approval for the signed JWT auto sign-in flow. +func (im *IdentifierIdentityManager) authorizeSignedLogin(ctx context.Context, rw http.ResponseWriter, req *http.Request, ar *payload.AuthenticationRequest, auth identity.AuthRecord) (identity.AuthRecord, error) { + if ar.Request == nil { + return nil, ar.NewError(oidc.ErrorCodeOIDCInvalidRequestObject, "IdentifierIdentityManager: authorize without request object") + } + + roc, ok := ar.Request.Claims.(*payload.RequestObjectClaims) + if !ok { + return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "IdentifierIdentityManager: authorize with invalid claims request") + } + + securedDetails := roc.Secure() + if securedDetails == nil { + return nil, ar.NewBadRequest(oidc.ErrorCodeOIDCInvalidRequestObject, "IdentifierIdentityManager: authorize without secure client") + } + + clientDetails, err := im.clients.Lookup(req.Context(), ar.ClientID, "", ar.RedirectURI, "", true) + if err != nil { + return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, err.Error()) + } + if clientDetails.ID != securedDetails.ID { + return nil, ar.NewError(oidc.ErrorCodeOAuth2AccessDenied, "client mismatch") + } + + var approvedScopes map[string]bool + if clientDetails.Trusted && securedDetails.TrustedScopes == nil { + approvedScopes = ar.Scopes + } else { + // Approve openid plus any scope in the client's trusted_scopes list. + approvedScopes = make(map[string]bool) + for _, scope := range securedDetails.TrustedScopes { + if ar.Scopes[scope] { + approvedScopes[scope] = true + } + } + if ar.Scopes[oidc.ScopeOpenID] { + approvedScopes[oidc.ScopeOpenID] = true + } + + // Ensure the signed-login scope was approved. + if !approvedScopes[konnect.ScopeSignedLoginOK] { + return nil, ar.NewBadRequest(oidc.ErrorCodeOAuth2InvalidRequest, "IdentifierIdentityManager: client does not authorize "+konnect.ScopeSignedLoginOK+" scope") + } + } + + auth.AuthorizeScopes(approvedScopes) + auth.AuthorizeClaims(ar.Claims) + return auth, nil +} + // EndSession implements the identity.Manager interface. func (im *IdentifierIdentityManager) EndSession(ctx context.Context, rw http.ResponseWriter, req *http.Request, esr *payload.EndSessionRequest) error { var err error diff --git a/oidc/provider/handlers.go b/oidc/provider/handlers.go index b65a30f7..d9d0c516 100644 --- a/oidc/provider/handlers.go +++ b/oidc/provider/handlers.go @@ -90,6 +90,7 @@ func (p *Provider) JwksHandler(rw http.ResponseWriter, req *http.Request) { func (p *Provider) AuthorizeHandler(rw http.ResponseWriter, req *http.Request) { var err error var auth identity.AuthRecord + var nextManager identity.Manager addResponseHeaders(rw.Header()) @@ -173,7 +174,8 @@ func (p *Provider) AuthorizeHandler(rw http.ResponseWriter, req *http.Request) { // Authorization Server Authenticates End-User // http://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthenticates - auth, err = p.identityManager.Authenticate(ctx, rw, req, ar, p.guestManager) + nextManager = p.guestManager + auth, err = p.identityManager.Authenticate(ctx, rw, req, ar, nextManager) if err != nil { goto done } diff --git a/scopes.go b/scopes.go index 6469ac53..6934f7bc 100644 --- a/scopes.go +++ b/scopes.go @@ -28,4 +28,7 @@ const ( // ScopeGuestOK is the string value for the built-in Guest OK scope. ScopeGuestOK = "LibreGraph.GuestOK" + + // ScopeSignedLoginOK is the string value for the built-in Signed Login OK scope. + ScopeSignedLoginOK = "LibreGraph.SignedLoginOK" )