forked from baron/baron-sso
OIDC back-channel logout 백엔드 전송 기능 추가
This commit is contained in:
@@ -572,6 +572,7 @@ func main() {
|
|||||||
auth.Post("/qr/init", authHandler.InitQRLogin)
|
auth.Post("/qr/init", authHandler.InitQRLogin)
|
||||||
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
auth.Post("/qr/poll", authHandler.PollQRLogin)
|
||||||
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
auth.Post("/qr/approve", authHandler.ScanQRLogin)
|
||||||
|
auth.Get("/backchannel/jwks.json", authHandler.GetBackchannelLogoutJWKS)
|
||||||
|
|
||||||
// Signup Routes
|
// Signup Routes
|
||||||
signup := auth.Group("/signup")
|
signup := auth.Group("/signup")
|
||||||
|
|||||||
@@ -6,30 +6,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
MetadataHeadlessLoginEnabled = "headless_login_enabled"
|
||||||
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method"
|
||||||
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
MetadataHeadlessJWKSURI = "headless_jwks_uri"
|
||||||
MetadataHeadlessJWKS = "headless_jwks"
|
MetadataHeadlessJWKS = "headless_jwks"
|
||||||
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
MetadataRequestObjectSigningAlg = "request_object_signing_alg"
|
||||||
MetadataIDTokenClaims = "id_token_claims"
|
MetadataIDTokenClaims = "id_token_claims"
|
||||||
|
MetadataBackChannelLogoutURI = "backchannel_logout_uri"
|
||||||
|
MetadataBackChannelLogoutSessionRequired = "backchannel_logout_session_required"
|
||||||
MetadataAutoLoginSupported = "auto_login_supported"
|
MetadataAutoLoginSupported = "auto_login_supported"
|
||||||
MetadataAutoLoginURL = "auto_login_url"
|
MetadataAutoLoginURL = "auto_login_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
type HydraClient struct {
|
type HydraClient struct {
|
||||||
ClientID string `json:"client_id"`
|
ClientID string `json:"client_id"`
|
||||||
ClientName string `json:"client_name,omitempty"`
|
ClientName string `json:"client_name,omitempty"`
|
||||||
ClientSecret string `json:"client_secret,omitempty"` // Added
|
ClientSecret string `json:"client_secret,omitempty"` // Added
|
||||||
ClientURI string `json:"client_uri,omitempty"`
|
ClientURI string `json:"client_uri,omitempty"`
|
||||||
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
RedirectURIs []string `json:"redirect_uris,omitempty"`
|
||||||
GrantTypes []string `json:"grant_types,omitempty"`
|
GrantTypes []string `json:"grant_types,omitempty"`
|
||||||
ResponseTypes []string `json:"response_types,omitempty"`
|
ResponseTypes []string `json:"response_types,omitempty"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
|
||||||
SkipConsent *bool `json:"skip_consent,omitempty"`
|
SkipConsent *bool `json:"skip_consent,omitempty"`
|
||||||
JWKSUri string `json:"jwks_uri,omitempty"`
|
JWKSUri string `json:"jwks_uri,omitempty"`
|
||||||
JWKS interface{} `json:"jwks,omitempty"`
|
JWKS interface{} `json:"jwks,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,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 {
|
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||||
@@ -87,6 +91,29 @@ func (c *HydraClient) IsHeadlessLoginEnabled() bool {
|
|||||||
return false
|
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 {
|
type HydraConsentRequest struct {
|
||||||
Challenge string `json:"challenge"`
|
Challenge string `json:"challenge"`
|
||||||
RequestedScope []string `json:"requested_scope"`
|
RequestedScope []string `json:"requested_scope"`
|
||||||
|
|||||||
@@ -85,20 +85,21 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
SmsService domain.SmsService
|
SmsService domain.SmsService
|
||||||
EmailService domain.EmailService
|
EmailService domain.EmailService
|
||||||
RedisService domain.RedisRepository
|
RedisService domain.RedisRepository
|
||||||
HeadlessJWKS *service.HeadlessJWKSCacheService
|
HeadlessJWKS *service.HeadlessJWKSCacheService
|
||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
IdpProvider domain.IdentityProvider
|
IdpProvider domain.IdentityProvider
|
||||||
AuditRepo domain.AuditRepository
|
AuditRepo domain.AuditRepository
|
||||||
OathkeeperRepo domain.OathkeeperLogRepository
|
OathkeeperRepo domain.OathkeeperLogRepository
|
||||||
Hydra *service.HydraAdminService
|
Hydra *service.HydraAdminService
|
||||||
TenantService service.TenantService
|
BackchannelLogout *service.BackchannelLogoutService
|
||||||
KetoService service.KetoService
|
TenantService service.TenantService
|
||||||
KetoOutboxRepo repository.KetoOutboxRepository
|
KetoService service.KetoService
|
||||||
UserRepo repository.UserRepository
|
KetoOutboxRepo repository.KetoOutboxRepository
|
||||||
ConsentRepo repository.ClientConsentRepository
|
UserRepo repository.UserRepository
|
||||||
|
ConsentRepo repository.ClientConsentRepository
|
||||||
RPUserMetadataRepo repository.RPUserMetadataRepository
|
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 {
|
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{
|
return &AuthHandler{
|
||||||
SmsService: service.NewSmsService(),
|
SmsService: service.NewSmsService(),
|
||||||
EmailService: service.NewEmailService(),
|
EmailService: service.NewEmailService(),
|
||||||
RedisService: redisService,
|
RedisService: redisService,
|
||||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
|
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
|
||||||
KratosAdmin: kratos,
|
KratosAdmin: kratos,
|
||||||
IdpProvider: idpProvider,
|
IdpProvider: idpProvider,
|
||||||
AuditRepo: auditRepo,
|
AuditRepo: auditRepo,
|
||||||
OathkeeperRepo: oathkeeperRepo,
|
OathkeeperRepo: oathkeeperRepo,
|
||||||
Hydra: service.NewHydraAdminService(),
|
Hydra: service.NewHydraAdminService(),
|
||||||
TenantService: tenantService,
|
BackchannelLogout: backchannelLogout,
|
||||||
KetoService: ketoService,
|
TenantService: tenantService,
|
||||||
KetoOutboxRepo: ketoOutboxRepo,
|
KetoService: ketoService,
|
||||||
UserRepo: userRepo,
|
KetoOutboxRepo: ketoOutboxRepo,
|
||||||
ConsentRepo: consentRepo,
|
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{
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Link revoked successfully",
|
"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 {
|
if err := h.revokeHydraSessionAccess(c.Context(), profile.ID, targetSessionID); err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "Failed to revoke linked app sessions")
|
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)
|
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
|
||||||
return c.JSON(fiber.Map{"status": "ok"})
|
return c.JSON(fiber.Map{"status": "ok"})
|
||||||
@@ -8187,6 +8196,129 @@ func (h *AuthHandler) revokeHydraSessionAccess(ctx context.Context, userID strin
|
|||||||
return nil
|
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 {
|
func looksLikeInternalUserAgent(userAgent string) bool {
|
||||||
normalized := strings.ToLower(strings.TrimSpace(userAgent))
|
normalized := strings.ToLower(strings.TrimSpace(userAgent))
|
||||||
if normalized == "" {
|
if normalized == "" {
|
||||||
|
|||||||
@@ -94,19 +94,21 @@ type devStatsResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clientSummary struct {
|
type clientSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
CreatedAt *time.Time `json:"createdAt,omitempty"`
|
||||||
RedirectURIs []string `json:"redirectUris"`
|
RedirectURIs []string `json:"redirectUris"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
ClientSecret string `json:"clientSecret,omitempty"`
|
ClientSecret string `json:"clientSecret,omitempty"`
|
||||||
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
|
TokenEndpointAuthMethod string `json:"tokenEndpointAuthMethod,omitempty"`
|
||||||
SkipConsent bool `json:"skipConsent"`
|
SkipConsent bool `json:"skipConsent"`
|
||||||
JwksUri string `json:"jwksUri,omitempty"`
|
JwksUri string `json:"jwksUri,omitempty"`
|
||||||
Jwks interface{} `json:"jwks,omitempty"`
|
Jwks interface{} `json:"jwks,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
BackchannelLogoutURI string `json:"backchannelLogoutUri,omitempty"`
|
||||||
|
BackchannelLogoutSessionRequired bool `json:"backchannelLogoutSessionRequired"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type clientListResponse struct {
|
type clientListResponse struct {
|
||||||
@@ -179,19 +181,21 @@ type consentListResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clientUpsertRequest struct {
|
type clientUpsertRequest struct {
|
||||||
ID *string `json:"id"`
|
ID *string `json:"id"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Type *string `json:"type"`
|
Type *string `json:"type"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
RedirectURIs *[]string `json:"redirectUris"`
|
RedirectURIs *[]string `json:"redirectUris"`
|
||||||
Scopes *[]string `json:"scopes"`
|
Scopes *[]string `json:"scopes"`
|
||||||
GrantTypes *[]string `json:"grantTypes"`
|
GrantTypes *[]string `json:"grantTypes"`
|
||||||
ResponseTypes *[]string `json:"responseTypes"`
|
ResponseTypes *[]string `json:"responseTypes"`
|
||||||
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
TokenEndpointAuthMethod *string `json:"tokenEndpointAuthMethod"`
|
||||||
SkipConsent *bool `json:"skipConsent"`
|
SkipConsent *bool `json:"skipConsent"`
|
||||||
JwksUri *string `json:"jwksUri"`
|
JwksUri *string `json:"jwksUri"`
|
||||||
Jwks interface{} `json:"jwks"`
|
Jwks interface{} `json:"jwks"`
|
||||||
Metadata *map[string]interface{} `json:"metadata"`
|
BackchannelLogoutURI *string `json:"backchannelLogoutUri"`
|
||||||
|
BackchannelLogoutSessionRequired *bool `json:"backchannelLogoutSessionRequired"`
|
||||||
|
Metadata *map[string]interface{} `json:"metadata"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type normalizedIDTokenClaim struct {
|
type normalizedIDTokenClaim struct {
|
||||||
@@ -1679,9 +1683,15 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
if tenantID != "" {
|
if tenantID != "" {
|
||||||
metadata["tenant_id"] = tenantID
|
metadata["tenant_id"] = tenantID
|
||||||
}
|
}
|
||||||
|
var err error
|
||||||
metadata["status"] = status
|
metadata["status"] = status
|
||||||
metadata["created_at"] = time.Now().Format(time.RFC3339)
|
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)
|
metadata, err = normalizeClientTenantAccessMetadata(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||||
@@ -1711,17 +1721,19 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
clientReq := domain.HydraClient{
|
clientReq := domain.HydraClient{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
ClientName: name,
|
ClientName: name,
|
||||||
RedirectURIs: redirectURIs,
|
RedirectURIs: redirectURIs,
|
||||||
GrantTypes: grantTypes,
|
GrantTypes: grantTypes,
|
||||||
ResponseTypes: responseTypes,
|
ResponseTypes: responseTypes,
|
||||||
Scope: strings.Join(scopes, " "),
|
Scope: strings.Join(scopes, " "),
|
||||||
TokenEndpointAuthMethod: tokenAuthMethod,
|
TokenEndpointAuthMethod: tokenAuthMethod,
|
||||||
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
|
SkipConsent: boolPtr(valueOrBool(req.SkipConsent, true)),
|
||||||
JWKSUri: jwksURI,
|
JWKSUri: jwksURI,
|
||||||
JWKS: jwks,
|
JWKS: jwks,
|
||||||
Metadata: metadata,
|
BackChannelLogoutURI: backchannelLogoutURI,
|
||||||
|
BackChannelLogoutSessionRequired: boolPtr(backchannelLogoutSessionRequired),
|
||||||
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
h.setAuditDetailsExtra(c, map[string]any{
|
h.setAuditDetailsExtra(c, map[string]any{
|
||||||
@@ -1866,6 +1878,16 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
metadata["status"] = status
|
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)
|
metadata, err = normalizeClientTenantAccessMetadata(metadata)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
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))
|
resolvedSkipConsent := valueOrBool(req.SkipConsent, valueOrBool(current.SkipConsent, true))
|
||||||
|
|
||||||
updated := domain.HydraClient{
|
updated := domain.HydraClient{
|
||||||
ClientID: current.ClientID,
|
ClientID: current.ClientID,
|
||||||
ClientName: valueOr(req.Name, current.ClientName),
|
ClientName: valueOr(req.Name, current.ClientName),
|
||||||
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
|
RedirectURIs: derefSlice(req.RedirectURIs, current.RedirectURIs),
|
||||||
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
|
GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes),
|
||||||
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes),
|
||||||
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))),
|
||||||
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
TokenEndpointAuthMethod: resolvedTokenAuthMethod,
|
||||||
SkipConsent: boolPtr(resolvedSkipConsent),
|
SkipConsent: boolPtr(resolvedSkipConsent),
|
||||||
JWKSUri: resolvedJWKSURI,
|
JWKSUri: resolvedJWKSURI,
|
||||||
JWKS: resolvedJWKS,
|
JWKS: resolvedJWKS,
|
||||||
Metadata: metadata,
|
BackChannelLogoutURI: strings.TrimSpace(resolvedBackchannelLogoutURI),
|
||||||
|
BackChannelLogoutSessionRequired: boolPtr(resolvedBackchannelLogoutSessionRequired),
|
||||||
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil {
|
||||||
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
return errorJSON(c, fiber.StatusForbidden, err.Error())
|
||||||
@@ -2651,19 +2675,21 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return clientSummary{
|
return clientSummary{
|
||||||
ID: client.ClientID,
|
ID: client.ClientID,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: clientType,
|
Type: clientType,
|
||||||
Status: status,
|
Status: status,
|
||||||
CreatedAt: createdAt,
|
CreatedAt: createdAt,
|
||||||
RedirectURIs: client.RedirectURIs,
|
RedirectURIs: client.RedirectURIs,
|
||||||
Scopes: scopes,
|
Scopes: scopes,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
|
TokenEndpointAuthMethod: client.TokenEndpointAuthMethod,
|
||||||
SkipConsent: valueOrBool(client.SkipConsent, true),
|
SkipConsent: valueOrBool(client.SkipConsent, true),
|
||||||
JwksUri: client.JWKSUri,
|
JwksUri: client.JWKSUri,
|
||||||
Jwks: client.JWKS,
|
Jwks: client.JWKS,
|
||||||
Metadata: client.Metadata,
|
BackchannelLogoutURI: client.BackchannelLogoutURI(),
|
||||||
|
BackchannelLogoutSessionRequired: client.BackchannelLogoutSessionRequiredValue(),
|
||||||
|
Metadata: client.Metadata,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2683,6 +2709,58 @@ func readMetadataBoolValue(metadata map[string]interface{}, key string) bool {
|
|||||||
return value
|
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) {
|
func normalizeClientAutoLoginMetadata(metadata map[string]interface{}) (map[string]interface{}, error) {
|
||||||
if metadata == nil {
|
if metadata == nil {
|
||||||
return metadata, nil
|
return metadata, nil
|
||||||
|
|||||||
192
backend/internal/service/backchannel_logout_service.go
Normal file
192
backend/internal/service/backchannel_logout_service.go
Normal file
@@ -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())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user