1
0
forked from baron/baron-sso

chore(headless-login): add request correlation logs

This commit is contained in:
Lectom C Han
2026-04-01 19:42:09 +09:00
parent c3ae316570
commit 8bab8d44cc
2 changed files with 588 additions and 23 deletions

View File

@@ -126,6 +126,34 @@ type headlessClientAssertionClaims struct {
type headlessAssertionAud []string
type headlessLoginFailure struct {
status int
code string
safeMessage string
logMessage string
debugFields map[string]any
}
func (e *headlessLoginFailure) Error() string {
if e == nil {
return ""
}
if e.code != "" {
return e.code
}
return e.safeMessage
}
func newHeadlessLoginFailure(status int, code, safeMessage, logMessage string, debugFields map[string]any) *headlessLoginFailure {
return &headlessLoginFailure{
status: status,
code: code,
safeMessage: safeMessage,
logMessage: logMessage,
debugFields: debugFields,
}
}
func (a *headlessAssertionAud) UnmarshalJSON(data []byte) error {
var single string
if err := json.Unmarshal(data, &single); err == nil {
@@ -1742,6 +1770,117 @@ func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bo
return false
}
func headlessRequestID(c *fiber.Ctx) string {
if c == nil {
return ""
}
reqID := strings.TrimSpace(c.GetRespHeader(fiber.HeaderXRequestID))
if reqID != "" {
return reqID
}
return strings.TrimSpace(c.Get(fiber.HeaderXRequestID))
}
func isHeadlessDebugLoggingEnabled() bool {
return slog.Default().Enabled(context.Background(), slog.LevelDebug)
}
func truncateHeadlessLogValue(value string, limit int) string {
value = strings.TrimSpace(value)
if limit <= 0 || len(value) <= limit {
return value
}
return value[:limit]
}
func logHeadlessLoginFailure(c *fiber.Ctx, message string, failure *headlessLoginFailure, clientID, loginChallenge string) {
if failure == nil {
return
}
args := []any{
"reason_code", failure.code,
"client_id", strings.TrimSpace(clientID),
"path", c.Path(),
}
if reqID := headlessRequestID(c); reqID != "" {
args = append(args, "req_id", reqID)
}
if trimmedChallenge := truncateHeadlessLogValue(loginChallenge, 12); trimmedChallenge != "" {
args = append(args, "login_challenge_prefix", trimmedChallenge)
}
if isHeadlessDebugLoggingEnabled() {
keys := make([]string, 0, len(failure.debugFields))
for key := range failure.debugFields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
args = append(args, key, failure.debugFields[key])
}
}
level := slog.LevelWarn
if failure.status >= 500 {
level = slog.LevelError
}
slog.Log(context.Background(), level, message, args...)
}
func logHeadlessLoginSuccess(c *fiber.Ctx, clientID, loginChallenge, redirectTo string) {
args := []any{
"client_id", strings.TrimSpace(clientID),
"path", c.Path(),
"response_status", fiber.StatusOK,
}
if reqID := headlessRequestID(c); reqID != "" {
args = append(args, "req_id", reqID)
}
if trimmedChallenge := truncateHeadlessLogValue(loginChallenge, 12); trimmedChallenge != "" {
args = append(args, "login_challenge_prefix", trimmedChallenge)
}
parsed, err := url.Parse(redirectTo)
if err != nil {
args = append(args, "redirect_to_length", len(redirectTo), "redirect_parse_error", err.Error())
slog.Info("headless password login succeeded", args...)
return
}
query := parsed.Query()
args = append(
args,
"redirect_to_length", len(redirectTo),
"redirect_to_host", parsed.Host,
"redirect_to_path", parsed.Path,
"redirect_has_login_verifier", query.Has("login_verifier"),
"redirect_has_redirect_uri", query.Has("redirect_uri"),
)
slog.Info("headless password login succeeded", args...)
}
func respondHeadlessLoginFailure(c *fiber.Ctx, failure *headlessLoginFailure) error {
if failure == nil {
return nil
}
return errorJSONCode(c, failure.status, failure.code, failure.safeMessage)
}
func newHeadlessCredentialFailure(status int, code, safeMessage string) *headlessLoginFailure {
return newHeadlessLoginFailure(
status,
code,
safeMessage,
"headless password login credential authentication failed",
nil,
)
}
func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient, expectedKid string) (*jose.JSONWebKeySet, bool, error) {
if h.HeadlessJWKS == nil {
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.RedisService, nil)
@@ -1753,30 +1892,75 @@ func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraC
return keySet, refreshed, nil
}
func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAssertionClaims, clientID string) error {
func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAssertionClaims, clientID string) *headlessLoginFailure {
now := time.Now().Unix()
debugFields := map[string]any{
"claim_issuer": claims.Issuer,
"claim_subject": claims.Subject,
"claim_expires_at": claims.ExpiresAt,
"claim_not_before": claims.NotBefore,
"claim_issued_at": claims.IssuedAt,
"received_audiences": []string(claims.Audience),
"expected_audiences": headlessAssertionAudiences(c),
}
if claims.Issuer != clientID || claims.Subject != clientID {
return fmt.Errorf("client assertion iss/sub mismatch")
return newHeadlessLoginFailure(
fiber.StatusUnauthorized,
"invalid_client_assertion_iss_sub",
"Client assertion issuer or subject mismatch",
"headless password login client assertion claims mismatch",
debugFields,
)
}
if claims.ExpiresAt == 0 || claims.ExpiresAt <= now {
return fmt.Errorf("client assertion expired")
return newHeadlessLoginFailure(
fiber.StatusUnauthorized,
"invalid_client_assertion_expired",
"Client assertion has expired",
"headless password login client assertion expired",
debugFields,
)
}
if claims.NotBefore != 0 && claims.NotBefore > now {
return fmt.Errorf("client assertion not active yet")
return newHeadlessLoginFailure(
fiber.StatusUnauthorized,
"invalid_client_assertion_not_before",
"Client assertion is not active yet",
"headless password login client assertion not active yet",
debugFields,
)
}
if claims.IssuedAt != 0 && claims.IssuedAt > now+60 {
return fmt.Errorf("client assertion issued in the future")
return newHeadlessLoginFailure(
fiber.StatusUnauthorized,
"invalid_client_assertion_iat_future",
"Client assertion issued-at time is invalid",
"headless password login client assertion issued in the future",
debugFields,
)
}
if !containsHeadlessAudience(headlessAssertionAudiences(c), claims.Audience) {
return fmt.Errorf("client assertion audience mismatch")
return newHeadlessLoginFailure(
fiber.StatusUnauthorized,
"invalid_client_assertion_audience",
"Client assertion audience mismatch",
"headless password login client assertion audience mismatch",
debugFields,
)
}
return nil
}
func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.HydraClient, clientID, clientAssertion string) error {
func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.HydraClient, clientID, clientAssertion string) *headlessLoginFailure {
assertion := strings.TrimSpace(clientAssertion)
if assertion == "" {
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_assertion is required")
return newHeadlessLoginFailure(
fiber.StatusBadRequest,
"bad_request",
"client_assertion is required",
"headless password login client assertion missing",
nil,
)
}
token, err := josejwt.ParseSigned(assertion, []jose.SignatureAlgorithm{
@@ -1786,7 +1970,13 @@ func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.
jose.EdDSA,
})
if err != nil {
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion")
return newHeadlessLoginFailure(
fiber.StatusUnauthorized,
"invalid_client_assertion_parse",
"Client assertion format is invalid",
"headless password login client assertion parse failed",
nil,
)
}
expectedKid := ""
@@ -1797,7 +1987,15 @@ func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.
keySet, refreshed, err := h.loadHeadlessJWKS(c.Context(), client, expectedKid)
if err != nil {
slog.Error("failed to load jwks for headless client assertion", "clientID", clientID, "error", err)
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", headlessClientAssertionErrorMessage(err))
return newHeadlessLoginFailure(
fiber.StatusUnauthorized,
"invalid_client_assertion_jwks_load",
headlessClientAssertionErrorMessage(err),
"headless password login client assertion jwks load failed",
map[string]any{
"received_kid": expectedKid,
},
)
}
matchingKidPresent := expectedKid != "" && containsHeadlessKeyID(keySet, expectedKid)
@@ -1810,8 +2008,13 @@ func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.
if err := token.Claims(key.Key, &claims); err != nil {
continue
}
if err := validateHeadlessClientAssertionClaims(c, claims, clientID); err != nil {
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion claims")
if failure := validateHeadlessClientAssertionClaims(c, claims, clientID); failure != nil {
if failure.debugFields == nil {
failure.debugFields = map[string]any{}
}
failure.debugFields["received_kid"] = expectedKid
failure.debugFields["jwks_refreshed"] = refreshed
return failure
}
_ = h.HeadlessJWKS.MarkVerificationSuccess(clientID)
return nil
@@ -1829,8 +2032,13 @@ func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.
if err := token.Claims(key.Key, &claims); err != nil {
continue
}
if err := validateHeadlessClientAssertionClaims(c, claims, clientID); err != nil {
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion claims")
if failure := validateHeadlessClientAssertionClaims(c, claims, clientID); failure != nil {
if failure.debugFields == nil {
failure.debugFields = map[string]any{}
}
failure.debugFields["received_kid"] = expectedKid
failure.debugFields["jwks_refreshed"] = true
return failure
}
_ = h.HeadlessJWKS.MarkVerificationSuccess(clientID)
return nil
@@ -1838,7 +2046,16 @@ func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.
}
}
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion signature with jwksUri")
return newHeadlessLoginFailure(
fiber.StatusUnauthorized,
"invalid_client_assertion_signature",
"Client assertion signature verification failed",
"headless password login client assertion signature verification failed",
map[string]any{
"received_kid": expectedKid,
"jwks_refreshed": refreshed,
},
)
}
func headlessClientAssertionErrorMessage(err error) string {
@@ -2045,13 +2262,21 @@ func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error {
if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
return err
}
if err := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); err != nil {
return err
if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, loginChallenge)
return respondHeadlessLoginFailure(c, failure)
}
authInfo, authErr := h.authenticatePasswordLogin(c.Context(), loginID, req.Password)
if authErr != nil {
status, code, message := passwordLoginErrorSpec(authErr)
logHeadlessLoginFailure(
c,
"headless password login credential authentication failed",
newHeadlessCredentialFailure(status, code, message),
clientID,
loginChallenge,
)
return errorJSONCode(c, status, code, message)
}
@@ -2064,11 +2289,15 @@ func (h *AuthHandler) HeadlessPasswordLogin(c *fiber.Ctx) error {
}
logOidcRedirectSummary("headless_password_login", acceptResp.RedirectTo)
return c.JSON(fiber.Map{
if err := c.Status(fiber.StatusOK).JSON(fiber.Map{
"redirectTo": acceptResp.RedirectTo,
"status": "ok",
"provider": h.IdpProvider.Name(),
})
}); err != nil {
return err
}
logHeadlessLoginSuccess(c, clientID, loginChallenge, acceptResp.RedirectTo)
return nil
}
func (h *AuthHandler) startHeadlessPhoneLink(c *fiber.Ctx, loginID string) (fiber.Map, string, string, time.Duration, error) {
@@ -2194,8 +2423,9 @@ func (h *AuthHandler) HeadlessLinkInit(c *fiber.Ctx) error {
if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
return err
}
if err := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); err != nil {
return err
if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, loginChallenge)
return respondHeadlessLoginFailure(c, failure)
}
resp, pendingRef, resolvedLoginID, ttl, err := h.startHeadlessPhoneLink(c, loginID)
@@ -2255,8 +2485,9 @@ func (h *AuthHandler) HeadlessLinkPoll(c *fiber.Ctx) error {
if err := h.validateHeadlessPasswordLoginClient(loginReq, clientID); err != nil {
return err
}
if err := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); err != nil {
return err
if failure := h.verifyHeadlessClientAssertion(c, loginReq.Client, clientID, req.ClientAssertion); failure != nil {
logHeadlessLoginFailure(c, failure.logMessage, failure, clientID, state.LoginChallenge)
return respondHeadlessLoginFailure(c, failure)
}
val, err := h.RedisService.Get(prefixSession + pendingRef)