From f9f0ed0f140fe71f52e2cf971ce7d25bcd8004e0 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 4 May 2026 11:03:27 +0900 Subject: [PATCH] =?UTF-8?q?OIDC=20back-channel=20logout=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=A0=84=EC=86=A1=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 1 + backend/internal/domain/hydra_models.go | 65 ++++-- backend/internal/handler/auth_handler.go | 188 +++++++++++++--- backend/internal/handler/dev_handler.go | 202 ++++++++++++------ .../service/backchannel_logout_service.go | 192 +++++++++++++++++ 5 files changed, 539 insertions(+), 109 deletions(-) create mode 100644 backend/internal/service/backchannel_logout_service.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 13cfbe2b..e8bb208c 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -572,6 +572,7 @@ func main() { auth.Post("/qr/init", authHandler.InitQRLogin) auth.Post("/qr/poll", authHandler.PollQRLogin) auth.Post("/qr/approve", authHandler.ScanQRLogin) + auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS) // Signup Routes signup := auth.Group("/signup") diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go index f3bed2e3..0a09ef5a 100644 --- a/backend/internal/domain/hydra_models.go +++ b/backend/internal/domain/hydra_models.go @@ -6,30 +6,34 @@ import ( ) const ( - MetadataHeadlessLoginEnabled = "headless_login_enabled" - MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method" - MetadataHeadlessJWKSURI = "headless_jwks_uri" - MetadataHeadlessJWKS = "headless_jwks" - MetadataRequestObjectSigningAlg = "request_object_signing_alg" - MetadataIDTokenClaims = "id_token_claims" + MetadataHeadlessLoginEnabled = "headless_login_enabled" + MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method" + MetadataHeadlessJWKSURI = "headless_jwks_uri" + MetadataHeadlessJWKS = "headless_jwks" + MetadataRequestObjectSigningAlg = "request_object_signing_alg" + MetadataIDTokenClaims = "id_token_claims" + MetadataBackChannelLogoutURI = "backchannel_logout_uri" + MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required" MetadataAutoLoginSupported = "auto_login_supported" MetadataAutoLoginURL = "auto_login_url" ) type HydraClient struct { - ClientID string `json:"client_id"` - ClientName string `json:"client_name,omitempty"` - ClientSecret string `json:"client_secret,omitempty"` // Added - ClientURI string `json:"client_uri,omitempty"` - RedirectURIs []string `json:"redirect_uris,omitempty"` - GrantTypes []string `json:"grant_types,omitempty"` - ResponseTypes []string `json:"response_types,omitempty"` - Scope string `json:"scope,omitempty"` - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` - SkipConsent *bool `json:"skip_consent,omitempty"` - JWKSUri string `json:"jwks_uri,omitempty"` - JWKS interface{} `json:"jwks,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + ClientID string `json:"client_id"` + ClientName string `json:"client_name,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` // Added + ClientURI string `json:"client_uri,omitempty"` + RedirectURIs []string `json:"redirect_uris,omitempty"` + GrantTypes []string `json:"grant_types,omitempty"` + ResponseTypes []string `json:"response_types,omitempty"` + Scope string `json:"scope,omitempty"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + SkipConsent *bool `json:"skip_consent,omitempty"` + JWKSUri string `json:"jwks_uri,omitempty"` + JWKS interface{} `json:"jwks,omitempty"` + BackChannelLogoutURI string `json:"backchannel_logout_uri,omitempty"` + BackChannelLogoutSessionRequired *bool `json:"backchannel_logout_session_required,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } func (c *HydraClient) SupportsHeadlessLogin() bool { @@ -87,6 +91,29 @@ func (c *HydraClient) IsHeadlessLoginEnabled() bool { return false } +func (c *HydraClient) BackchannelLogoutURI() string { + if c.Metadata != nil { + if raw, ok := c.Metadata[MetadataBackChannelLogoutURI].(string); ok { + if value := strings.TrimSpace(raw); value != "" { + return value + } + } + } + return strings.TrimSpace(c.BackChannelLogoutURI) +} + +func (c *HydraClient) BackchannelLogoutSessionRequiredValue() bool { + if c.Metadata != nil { + if raw, ok := c.Metadata[MetadataBackChannelLogoutSessionRequired].(bool); ok { + return raw + } + } + if c.BackChannelLogoutSessionRequired != nil { + return *c.BackChannelLogoutSessionRequired + } + return false +} + type HydraConsentRequest struct { Challenge string `json:"challenge"` RequestedScope []string `json:"requested_scope"` diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3c83f230..e02f74f0 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -85,20 +85,21 @@ const ( ) type AuthHandler struct { - SmsService domain.SmsService - EmailService domain.EmailService - RedisService domain.RedisRepository - HeadlessJWKS *service.HeadlessJWKSCacheService - KratosAdmin service.KratosAdminService - IdpProvider domain.IdentityProvider - AuditRepo domain.AuditRepository - OathkeeperRepo domain.OathkeeperLogRepository - Hydra *service.HydraAdminService - TenantService service.TenantService - KetoService service.KetoService - KetoOutboxRepo repository.KetoOutboxRepository - UserRepo repository.UserRepository - ConsentRepo repository.ClientConsentRepository + SmsService domain.SmsService + EmailService domain.EmailService + RedisService domain.RedisRepository + HeadlessJWKS *service.HeadlessJWKSCacheService + KratosAdmin service.KratosAdminService + IdpProvider domain.IdentityProvider + AuditRepo domain.AuditRepository + OathkeeperRepo domain.OathkeeperLogRepository + Hydra *service.HydraAdminService + BackchannelLogout *service.BackchannelLogoutService + TenantService service.TenantService + KetoService service.KetoService + KetoOutboxRepo repository.KetoOutboxRepository + UserRepo repository.UserRepository + ConsentRepo repository.ClientConsentRepository RPUserMetadataRepo repository.RPUserMetadataRepository } @@ -221,21 +222,26 @@ func checkPollInterval(redis domain.RedisRepository, key string, interval time.D } func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository, kratos service.KratosAdminService) *AuthHandler { + backchannelLogout, err := service.NewBackchannelLogoutService() + if err != nil { + slog.Warn("failed to initialize backchannel logout service", "error", err) + } return &AuthHandler{ - SmsService: service.NewSmsService(), - EmailService: service.NewEmailService(), - RedisService: redisService, - HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil), - KratosAdmin: kratos, - IdpProvider: idpProvider, - AuditRepo: auditRepo, - OathkeeperRepo: oathkeeperRepo, - Hydra: service.NewHydraAdminService(), - TenantService: tenantService, - KetoService: ketoService, - KetoOutboxRepo: ketoOutboxRepo, - UserRepo: userRepo, - ConsentRepo: consentRepo, + SmsService: service.NewSmsService(), + EmailService: service.NewEmailService(), + RedisService: redisService, + HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil), + KratosAdmin: kratos, + IdpProvider: idpProvider, + AuditRepo: auditRepo, + OathkeeperRepo: oathkeeperRepo, + Hydra: service.NewHydraAdminService(), + BackchannelLogout: backchannelLogout, + TenantService: tenantService, + KetoService: ketoService, + KetoOutboxRepo: ketoOutboxRepo, + UserRepo: userRepo, + ConsentRepo: consentRepo, } } @@ -5348,6 +5354,8 @@ func (h *AuthHandler) RevokeLinkedRp(c *fiber.Ctx) error { }) } + h.triggerBackchannelLogoutForClient(c.Context(), c, subject, clientID, "") + return c.Status(fiber.StatusOK).JSON(fiber.Map{ "status": "success", "message": "Link revoked successfully", @@ -7768,6 +7776,7 @@ func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error { if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil { return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions") } + h.triggerBackchannelLogoutForSession(c.Context(), c, profile.ID, targetSessionID) h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result) return c.JSON(fiber.Map{"status": "ok"}) @@ -8187,6 +8196,129 @@ func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID strin return nil } +func (h *AuthHandler) triggerBackchannelLogoutForSession(ctx context.Context, c *fiber.Ctx, userID string, sessionID string) { + if h == nil || h.Hydra == nil { + return + } + + clientIDs := h.loadSessionClientBindings(ctx, userID)[strings.TrimSpace(sessionID)] + for _, clientID := range clientIDs { + h.triggerBackchannelLogoutForClient(ctx, c, userID, clientID, sessionID) + } +} + +func (h *AuthHandler) triggerBackchannelLogoutForClient(ctx context.Context, c *fiber.Ctx, userID string, clientID string, sessionID string) { + if h == nil || h.Hydra == nil || h.BackchannelLogout == nil { + return + } + + clientID = strings.TrimSpace(clientID) + userID = strings.TrimSpace(userID) + sessionID = strings.TrimSpace(sessionID) + if clientID == "" || userID == "" { + return + } + + client, err := h.Hydra.GetClient(ctx, clientID) + if err != nil { + h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, "", 0, "client_lookup_failed") + return + } + if client == nil { + h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "client_not_found") + return + } + + endpoint := client.BackchannelLogoutURI() + if endpoint == "" { + h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, "", 0, "uri_not_configured") + return + } + if client.BackchannelLogoutSessionRequiredValue() && sessionID == "" { + h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.skipped", userID, clientID, sessionID, endpoint, 0, "sid_required") + return + } + + logoutToken, err := h.BackchannelLogout.BuildLogoutToken(clientID, userID, sessionID) + if err != nil { + h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, 0, "token_build_failed") + return + } + + statusCode, err := h.BackchannelLogout.SendLogoutToken(ctx, endpoint, logoutToken) + if err != nil { + h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.failed", userID, clientID, sessionID, endpoint, statusCode, "request_failed") + return + } + + h.writeBackchannelLogoutAuditLog(c, "backchannel_logout.sent", userID, clientID, sessionID, endpoint, statusCode, "") +} + +func (h *AuthHandler) writeBackchannelLogoutAuditLog(c *fiber.Ctx, eventType string, userID string, clientID string, sessionID string, endpoint string, statusCode int, reason string) { + if h == nil || h.AuditRepo == nil { + return + } + + endpointHost := "" + if endpoint != "" { + if parsed, err := url.Parse(endpoint); err == nil { + endpointHost = parsed.Host + } + } + + details := map[string]any{ + "client_id": strings.TrimSpace(clientID), + "session_id": strings.TrimSpace(sessionID), + "endpoint_host": strings.TrimSpace(endpointHost), + "status_code": statusCode, + "retry_count": 0, + "logout_issuer": h.BackchannelLogout.Issuer(), + } + if reason != "" { + details["reason"] = reason + } + + raw, err := json.Marshal(details) + if err != nil { + return + } + + status := "success" + if strings.HasSuffix(eventType, ".failed") { + status = "failure" + } else if strings.HasSuffix(eventType, ".skipped") { + status = "skipped" + } + + ipAddress := "" + userAgent := "" + if c != nil { + ipAddress = extractClientIPFromHeaders(c) + userAgent = strings.TrimSpace(c.Get("User-Agent")) + } + + _ = h.AuditRepo.Create(&domain.AuditLog{ + EventID: fmt.Sprintf("backchannel-logout-%d", time.Now().UnixNano()), + Timestamp: time.Now().UTC(), + UserID: strings.TrimSpace(userID), + SessionID: strings.TrimSpace(sessionID), + EventType: eventType, + Status: status, + IPAddress: ipAddress, + UserAgent: userAgent, + Details: string(raw), + }) +} + +func (h *AuthHandler) GetBackchannelLogoutJWKS(c *fiber.Ctx) error { + if h == nil || h.BackchannelLogout == nil { + return errorJSON(c, fiber.StatusServiceUnavailable, "backchannel logout jwks unavailable") + } + c.Set(fiber.HeaderContentType, fiber.MIMEApplicationJSONCharsetUTF8) + c.Set(fiber.HeaderCacheControl, "no-store") + return c.JSON(h.BackchannelLogout.PublicJWKS()) +} + func looksLikeInternalUserAgent(userAgent string) bool { normalized := strings.ToLower(strings.TrimSpace(userAgent)) if normalized == "" { diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index 9f9d5fdd..1fbdb4a8 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -94,19 +94,21 @@ type devStatsResponse struct { } type clientSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - CreatedAt *time.Time `json:"createdAt,omitempty"` - RedirectURIs []string `json:"redirectUris"` - Scopes []string `json:"scopes"` - ClientSecret string `json:"clientSecret,omitempty"` - TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` - SkipConsent bool `json:"skipConsent"` - JwksUri string `json:"jwksUri,omitempty"` - Jwks interface{} `json:"jwks,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + CreatedAt *time.Time `json:"createdAt,omitempty"` + RedirectURIs []string `json:"redirectUris"` + Scopes []string `json:"scopes"` + ClientSecret string `json:"clientSecret,omitempty"` + TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"` + SkipConsent bool `json:"skipConsent"` + JwksUri string `json:"jwksUri,omitempty"` + Jwks interface{} `json:"jwks,omitempty"` + BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"` + BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"` + Metadata map[string]interface{} `json:"metadata,omitempty"` } type clientListResponse struct { @@ -179,19 +181,21 @@ type consentListResponse struct { } type clientUpsertRequest struct { - ID *string `json:"id"` - Name *string `json:"name"` - Type *string `json:"type"` - Status *string `json:"status"` - RedirectURIs *[]string `json:"redirectUris"` - Scopes *[]string `json:"scopes"` - GrantTypes *[]string `json:"grantTypes"` - ResponseTypes *[]string `json:"responseTypes"` - TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"` - SkipConsent *bool `json:"skipConsent"` - JwksUri *string `json:"jwksUri"` - Jwks interface{} `json:"jwks"` - Metadata *map[string]interface{} `json:"metadata"` + ID *string `json:"id"` + Name *string `json:"name"` + Type *string `json:"type"` + Status *string `json:"status"` + RedirectURIs *[]string `json:"redirectUris"` + Scopes *[]string `json:"scopes"` + GrantTypes *[]string `json:"grantTypes"` + ResponseTypes *[]string `json:"responseTypes"` + TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"` + SkipConsent *bool `json:"skipConsent"` + JwksUri *string `json:"jwksUri"` + Jwks interface{} `json:"jwks"` + BackchannelLogoutURI *string `json:"backchannelLogoutUri"` + BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"` + Metadata *map[string]interface{} `json:"metadata"` } type normalizedIDTokenClaim struct { @@ -1679,9 +1683,15 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { if tenantID != "" { metadata["tenant_id"] = tenantID } + var err error metadata["status"] = status metadata["created_at"] = time.Now().Format(time.RFC3339) - var err error + backchannelLogoutURI := strings.TrimSpace(valueOr(req.BackchannelLogoutURI, "")) + backchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, false) + metadata, err = normalizeBackchannelLogoutMetadata(metadata, backchannelLogoutURI, backchannelLogoutSessionRequired) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } metadata, err = normalizeClientTenantAccessMetadata(metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) @@ -1711,17 +1721,19 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { ) clientReq := domain.HydraClient{ - ClientID: clientID, - ClientName: name, - RedirectURIs: redirectURIs, - GrantTypes: grantTypes, - ResponseTypes: responseTypes, - Scope: strings.Join(scopes, " "), - TokenEndpointAuthMethod: tokenAuthMethod, - SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)), - JWKSUri: jwksURI, - JWKS: jwks, - Metadata: metadata, + ClientID: clientID, + ClientName: name, + RedirectURIs: redirectURIs, + GrantTypes: grantTypes, + ResponseTypes: responseTypes, + Scope: strings.Join(scopes, " "), + TokenEndpointAuthMethod: tokenAuthMethod, + SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)), + JWKSUri: jwksURI, + JWKS: jwks, + BackChannelLogoutURI: backchannelLogoutURI, + BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired), + Metadata: metadata, } h.setAuditDetailsExtra(c, map[string]any{ @@ -1866,6 +1878,16 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { } metadata["status"] = status } + resolvedBackchannelLogoutURI := valueOr(req.BackchannelLogoutURI, current.BackchannelLogoutURI()) + resolvedBackchannelLogoutSessionRequired := valueOrBool(req.BackchannelLogoutSessionRequired, current.BackchannelLogoutSessionRequiredValue()) + metadata, err = normalizeBackchannelLogoutMetadata( + metadata, + resolvedBackchannelLogoutURI, + resolvedBackchannelLogoutSessionRequired, + ) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } metadata, err = normalizeClientTenantAccessMetadata(metadata) if err != nil { return errorJSON(c, fiber.StatusBadRequest, err.Error()) @@ -1901,17 +1923,19 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { resolvedSkipConsent := valueOrBool(req.SkipConsent, valueOrBool(current.SkipConsent, true)) updated := domain.HydraClient{ - ClientID: current.ClientID, - ClientName: valueOr(req.Name, current.ClientName), - RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs), - GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes), - ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), - Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), - TokenEndpointAuthMethod: resolvedTokenAuthMethod, - SkipConsent: boolPtr(resolvedSkipConsent), - JWKSUri: resolvedJWKSURI, - JWKS: resolvedJWKS, - Metadata: metadata, + ClientID: current.ClientID, + ClientName: valueOr(req.Name, current.ClientName), + RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs), + GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes), + ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), + Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), + TokenEndpointAuthMethod: resolvedTokenAuthMethod, + SkipConsent: boolPtr(resolvedSkipConsent), + JWKSUri: resolvedJWKSURI, + JWKS: resolvedJWKS, + BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI), + BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired), + Metadata: metadata, } if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) @@ -2651,19 +2675,21 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { } return clientSummary{ - ID: client.ClientID, - Name: name, - Type: clientType, - Status: status, - CreatedAt: createdAt, - RedirectURIs: client.RedirectURIs, - Scopes: scopes, - ClientSecret: clientSecret, - TokenEndpointAuthMethod: client.TokenEndpointAuthMethod, - SkipConsent: valueOrBool(client.SkipConsent, true), - JwksUri: client.JWKSUri, - Jwks: client.JWKS, - Metadata: client.Metadata, + ID: client.ClientID, + Name: name, + Type: clientType, + Status: status, + CreatedAt: createdAt, + RedirectURIs: client.RedirectURIs, + Scopes: scopes, + ClientSecret: clientSecret, + TokenEndpointAuthMethod: client.TokenEndpointAuthMethod, + SkipConsent: valueOrBool(client.SkipConsent, true), + JwksUri: client.JWKSUri, + Jwks: client.JWKS, + BackchannelLogoutURI: client.BackchannelLogoutURI(), + BackchannelLogoutSessionRequired: client.BackchannelLogoutSessionRequiredValue(), + Metadata: client.Metadata, } } @@ -2683,6 +2709,58 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool { return value } +func normalizeBackchannelLogoutMetadata(metadata map[string]interface{}, logoutURI string, sessionRequired bool) (map[string]interface{}, error) { + if metadata == nil { + metadata = map[string]interface{}{} + } + + trimmedURI := strings.TrimSpace(logoutURI) + if err := validateBackchannelLogoutURI(trimmedURI); err != nil { + return nil, err + } + + if trimmedURI == "" { + delete(metadata, domain.MetadataBackChannelLogoutURI) + delete(metadata, domain.MetadataBackChannelLogoutSessionRequired) + return metadata, nil + } + + metadata[domain.MetadataBackChannelLogoutURI] = trimmedURI + metadata[domain.MetadataBackChannelLogoutSessionRequired] = sessionRequired + return metadata, nil +} + +func validateBackchannelLogoutURI(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + + parsed, err := url.Parse(trimmed) + if err != nil || parsed == nil { + return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL") + } + if parsed.Scheme == "" || parsed.Host == "" { + return fmt.Errorf("backchannelLogoutUri must be a valid absolute URL") + } + if parsed.Fragment != "" { + return fmt.Errorf("backchannelLogoutUri must not include a fragment") + } + + switch strings.ToLower(parsed.Scheme) { + case "https": + return nil + case "http": + host := strings.ToLower(parsed.Hostname()) + if host == "localhost" || host == "127.0.0.1" { + return nil + } + return fmt.Errorf("backchannelLogoutUri must use https outside localhost development") + default: + return fmt.Errorf("backchannelLogoutUri must use http or https") + } +} + func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) { if metadata == nil { return metadata, nil diff --git a/backend/internal/service/backchannel_logout_service.go b/backend/internal/service/backchannel_logout_service.go new file mode 100644 index 00000000..69325bc8 --- /dev/null +++ b/backend/internal/service/backchannel_logout_service.go @@ -0,0 +1,192 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "encoding/hex" + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/go-jose/go-jose/v4" + josejwt "github.com/go-jose/go-jose/v4/jwt" +) + +const backchannelLogoutEventURI = "http://schemas.openid.net/event/backchannel-logout" + +type BackchannelLogoutService struct { + issuer string + keyID string + signer jose.Signer + publicJWK jose.JSONWebKey + client *http.Client + HTTPClient *http.Client +} + +func NewBackchannelLogoutService() (*BackchannelLogoutService, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("failed to generate backchannel logout key: %w", err) + } + + keyID := randomBackchannelKeyID() + if keyID == "" { + keyID = fmt.Sprintf("bcl-%d", time.Now().UnixNano()) + } + + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.RS256, + Key: jose.JSONWebKey{ + Key: privateKey, + KeyID: keyID, + Algorithm: string(jose.RS256), + Use: "sig", + }, + }, (&jose.SignerOptions{}).WithType("JWT")) + if err != nil { + return nil, fmt.Errorf("failed to initialize backchannel logout signer: %w", err) + } + + return &BackchannelLogoutService{ + issuer: resolveBackchannelLogoutIssuer(), + keyID: keyID, + signer: signer, + publicJWK: jose.JSONWebKey{ + Key: &privateKey.PublicKey, + KeyID: keyID, + Algorithm: string(jose.RS256), + Use: "sig", + }, + client: &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 3 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 3 * time.Second, + }, + }, + }, nil +} + +func randomBackchannelKeyID() string { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "" + } + return hex.EncodeToString(buf) +} + +func resolveBackchannelLogoutIssuer() string { + if explicit := strings.TrimSpace(os.Getenv("BACKCHANNEL_LOGOUT_ISSUER")); explicit != "" { + return strings.TrimRight(explicit, "/") + } + + if hydraPublic := strings.TrimSpace(os.Getenv("HYDRA_PUBLIC_URL")); hydraPublic != "" { + return strings.TrimRight(hydraPublic, "/") + } + + if oathkeeperPublic := strings.TrimSpace(os.Getenv("OATHKEEPER_PUBLIC_URL")); oathkeeperPublic != "" { + return strings.TrimRight(oathkeeperPublic, "/") + "/oidc" + } + + if userfrontURL := strings.TrimSpace(os.Getenv("USERFRONT_URL")); userfrontURL != "" { + return strings.TrimRight(userfrontURL, "/") + "/oidc" + } + + return "http://localhost:5000/oidc" +} + +func (s *BackchannelLogoutService) Issuer() string { + if s == nil { + return "" + } + return s.issuer +} + +func (s *BackchannelLogoutService) PublicJWKS() map[string]any { + if s == nil { + return map[string]any{"keys": []any{}} + } + return map[string]any{ + "keys": []jose.JSONWebKey{s.publicJWK.Public()}, + } +} + +func (s *BackchannelLogoutService) BuildLogoutToken(clientID, subject, sessionID string) (string, error) { + if s == nil || s.signer == nil { + return "", fmt.Errorf("backchannel logout service is unavailable") + } + clientID = strings.TrimSpace(clientID) + subject = strings.TrimSpace(subject) + sessionID = strings.TrimSpace(sessionID) + if clientID == "" { + return "", fmt.Errorf("client id is required") + } + if subject == "" && sessionID == "" { + return "", fmt.Errorf("subject or session id is required") + } + + now := time.Now().UTC() + claims := josejwt.Claims{ + Issuer: s.issuer, + Audience: josejwt.Audience{clientID}, + IssuedAt: josejwt.NewNumericDate(now), + ID: fmt.Sprintf("%s-%d", s.keyID, now.UnixNano()), + } + if subject != "" { + claims.Subject = subject + } + + extra := map[string]any{ + "events": map[string]any{ + backchannelLogoutEventURI: map[string]any{}, + }, + } + if sessionID != "" { + extra["sid"] = sessionID + } + + return josejwt.Signed(s.signer).Claims(claims).Claims(extra).Serialize() +} + +func (s *BackchannelLogoutService) SendLogoutToken(ctx context.Context, endpoint, logoutToken string) (int, error) { + if s == nil { + return 0, fmt.Errorf("backchannel logout service is unavailable") + } + form := url.Values{} + form.Set("logout_token", logoutToken) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := s.client + if s.HTTPClient != nil { + client = s.HTTPClient + } + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return resp.StatusCode, fmt.Errorf("backchannel logout endpoint returned status %d", resp.StatusCode) + } + return resp.StatusCode, nil +} + +func (s *BackchannelLogoutService) MarshalPublicJWKS() ([]byte, error) { + return json.Marshal(s.PublicJWKS()) +}