Finish KEY-WP-0008: registration handoff and client MFA isolation
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 34s
Add signed registration/enrollment handoffs, per-request assurance policy with login-session isolation, and /logout. coulomb-social stays AAL1 unless acr_values or another client raises the bar.
This commit is contained in:
parent
fff9e39478
commit
b6af6c5268
22 changed files with 1636 additions and 42 deletions
|
|
@ -2,9 +2,11 @@ package oidc
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -28,6 +30,9 @@ type PendingState struct {
|
|||
ExpiresAt time.Time
|
||||
AuthenticatedUser string
|
||||
ACRValues []string
|
||||
TenantHint string
|
||||
MaxAge *time.Duration
|
||||
PromptLogin bool
|
||||
}
|
||||
|
||||
// pendingStateStore is a thread-safe map of state → PendingState.
|
||||
|
|
@ -65,6 +70,9 @@ type AuthorizeHandler struct {
|
|||
Auth domain.AuthProvider
|
||||
MFA domain.MFAProvider
|
||||
Sessions *SessionStore
|
||||
Logins *LoginSessionStore
|
||||
Handoffs *HandoffStore
|
||||
Issuer string
|
||||
Emitter telemetry.Emitter
|
||||
|
||||
pending *pendingStateStore
|
||||
|
|
@ -82,17 +90,28 @@ func (h *AuthorizeHandler) init() {
|
|||
if h.pending == nil {
|
||||
h.pending = newPendingStateStore()
|
||||
}
|
||||
if h.Logins == nil {
|
||||
h.Logins = NewLoginSessionStore()
|
||||
}
|
||||
if h.Handoffs == nil {
|
||||
h.Handoffs = NewHandoffStore()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ServeHTTP dispatches to the authorize or callback handler based on path.
|
||||
func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.init()
|
||||
if strings.HasSuffix(r.URL.Path, "/callback") {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/callback"):
|
||||
h.ServeHTTPCallback(w, r)
|
||||
return
|
||||
case strings.HasSuffix(r.URL.Path, "/return"):
|
||||
h.serveHandoffReturn(w, r)
|
||||
case strings.HasSuffix(r.URL.Path, "/register"):
|
||||
h.serveRegisterFromPending(w, r)
|
||||
default:
|
||||
h.serveAuthorize(w, r)
|
||||
}
|
||||
h.serveAuthorize(w, r)
|
||||
}
|
||||
|
||||
// serveAuthorize handles the initial GET /authorize request.
|
||||
|
|
@ -109,6 +128,14 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
codeChallenge := q.Get("code_challenge")
|
||||
codeChallengeMethod := q.Get("code_challenge_method")
|
||||
acrValues := strings.Fields(q.Get("acr_values"))
|
||||
tenantHint := firstNonEmpty(q.Get("tenant_hint"), q.Get("tenant"))
|
||||
promptCreate, promptLogin := parsePrompt(q.Get("prompt"))
|
||||
maxAge, maxAgeErr := parseMaxAge(q.Get("max_age"))
|
||||
if maxAgeErr != nil {
|
||||
profileerrors.InvalidProfileUsage("max_age must be a non-negative integer", "max_age").
|
||||
Write(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Emit auth_start telemetry immediately.
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
|
|
@ -189,7 +216,7 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
|
||||
// Store pending state so the callback can reconstruct the session.
|
||||
h.pending.Store(state, &PendingState{
|
||||
ps := &PendingState{
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
PKCEChallenge: codeChallenge,
|
||||
|
|
@ -198,8 +225,17 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
Nonce: nonce,
|
||||
Scopes: strings.Fields(scope),
|
||||
ACRValues: acrValues,
|
||||
TenantHint: tenantHint,
|
||||
MaxAge: maxAge,
|
||||
PromptLogin: promptLogin,
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
})
|
||||
}
|
||||
h.pending.Store(state, ps)
|
||||
|
||||
if promptCreate {
|
||||
h.startHandoff(w, r, ps, HandoffRegister)
|
||||
return
|
||||
}
|
||||
|
||||
// Delegate to Auth provider.
|
||||
authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{
|
||||
|
|
@ -256,7 +292,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
Code: code,
|
||||
State: state,
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil || result == nil || result.Username == "" {
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
EventType: telemetry.EventAuthFailure,
|
||||
|
|
@ -265,25 +301,16 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
Result: "failure",
|
||||
ErrorType: "auth_failed",
|
||||
})
|
||||
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if result == nil || result.Username == "" {
|
||||
if h.clientEligible(ps.ClientID, HandoffRegister) {
|
||||
h.renderUnknownUserSignup(w, ps)
|
||||
return
|
||||
}
|
||||
h.pending.Delete(state)
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
EventType: telemetry.EventAuthFailure,
|
||||
ClientID: ps.ClientID,
|
||||
Endpoint: "/authorize/callback",
|
||||
Result: "failure",
|
||||
ErrorType: "auth_failed",
|
||||
})
|
||||
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Check MFA requirement.
|
||||
mfaRequired, _, err := h.mfaRequirement(ps, result.Username)
|
||||
decision, err := h.decideAssurance(ctx, ps, result.Username, h.Logins.fromRequest(r))
|
||||
if err != nil {
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
|
|
@ -296,7 +323,13 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
http.Error(w, "mfa check error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if mfaRequired {
|
||||
if decision.RequireMFA {
|
||||
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil {
|
||||
http.Error(w, "enrollment check error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if handed {
|
||||
return
|
||||
}
|
||||
if mfaToken == "" {
|
||||
ps.AuthenticatedUser = result.Username
|
||||
h.pending.Store(state, ps)
|
||||
|
|
@ -304,29 +337,52 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
return
|
||||
}
|
||||
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
|
||||
if errors.Is(err, domain.ErrMFANotEnrolled) {
|
||||
if handed, herr := h.maybeEnrollmentHandoff(ctx, w, r, ps, result.Username); herr != nil {
|
||||
http.Error(w, "enrollment check error", http.StatusInternalServerError)
|
||||
return
|
||||
} else if handed {
|
||||
return
|
||||
}
|
||||
}
|
||||
h.pending.Delete(state)
|
||||
h.emitMFAFailure(ctx, ps.ClientID)
|
||||
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
h.pending.Delete(state)
|
||||
h.completeAuthorization(w, r, ps, result.Username, true)
|
||||
return
|
||||
}
|
||||
|
||||
h.pending.Delete(state)
|
||||
h.completeAuthorization(w, r, ps, result.Username, mfaRequired)
|
||||
h.completeAuthorization(w, r, ps, result.Username, decision.MFAVerified)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) mfaRequirement(ps *PendingState, username string) (bool, bool, error) {
|
||||
for _, acr := range ps.ACRValues {
|
||||
switch strings.ToLower(acr) {
|
||||
case "aal2", "mfa", "urn:netkingdom:aal2":
|
||||
return true, false, nil
|
||||
func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState, username string, login *LoginSession) (domain.AssuranceDecision, error) {
|
||||
client := h.ClientConfig[ps.ClientID]
|
||||
providerRequired := false
|
||||
if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) {
|
||||
var err error
|
||||
providerRequired, err = h.MFA.CheckMFARequired(ctx, username)
|
||||
if err != nil {
|
||||
return domain.AssuranceDecision{}, err
|
||||
}
|
||||
}
|
||||
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.MFARequired != nil {
|
||||
return *client.MFARequired, false, nil
|
||||
in := domain.AssuranceInput{
|
||||
Client: client,
|
||||
ACRValues: ps.ACRValues,
|
||||
ProviderRequired: providerRequired,
|
||||
RequestUser: username,
|
||||
PromptLogin: ps.PromptLogin,
|
||||
MaxAge: ps.MaxAge,
|
||||
}
|
||||
required, err := h.MFA.CheckMFARequired(context.Background(), username)
|
||||
return required, true, err
|
||||
if login != nil {
|
||||
in.SessionLevel = login.Level
|
||||
in.SessionUser = login.Username
|
||||
in.SessionIssuedAt = login.IssuedAt
|
||||
}
|
||||
return domain.DecideAssurance(in), nil
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -374,6 +430,14 @@ func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string, mfaVerified bool) {
|
||||
level := domain.AssuranceAAL1
|
||||
if mfaVerified {
|
||||
level = domain.AssuranceAAL2
|
||||
}
|
||||
if login := h.Logins.Create(username, level); login != nil {
|
||||
writeLoginCookie(w, login, issuerIsHTTPS(h.Issuer))
|
||||
}
|
||||
|
||||
// Generate authorization code and store PKCE session.
|
||||
sess := &PKCESession{
|
||||
ClientID: ps.ClientID,
|
||||
|
|
@ -411,6 +475,157 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
|
|||
http.Redirect(w, r, redirectTo.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) startHandoff(w http.ResponseWriter, r *http.Request, ps *PendingState, kind HandoffKind) {
|
||||
client, ok := h.ClientConfig[ps.ClientID]
|
||||
if !ok {
|
||||
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
|
||||
Write(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dest := client.RegistrationURL
|
||||
if kind == HandoffEnroll {
|
||||
dest = client.EnrollmentURL
|
||||
}
|
||||
if dest == "" {
|
||||
profileerrors.RejectedForSafety(
|
||||
"client is not eligible for this handoff",
|
||||
string(kind),
|
||||
).Write(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
token, err := h.Handoffs.Issue(kind, ps)
|
||||
if err != nil {
|
||||
http.Error(w, "handoff error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
loc, err := appendHandoff(dest, token)
|
||||
if err != nil {
|
||||
profileerrors.RejectedForSafety("handoff destination is not a valid URL", string(kind)).
|
||||
Write(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, loc, http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) serveRegisterFromPending(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
state := r.URL.Query().Get("state")
|
||||
ps, ok := h.pending.Load(state)
|
||||
if !ok {
|
||||
http.Error(w, "unknown or expired state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if time.Now().After(ps.ExpiresAt) {
|
||||
h.pending.Delete(state)
|
||||
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.startHandoff(w, r, ps, HandoffRegister)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) serveHandoffReturn(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
token := r.URL.Query().Get("kc_handoff")
|
||||
env, err := h.Handoffs.Consume(token)
|
||||
switch {
|
||||
case errors.Is(err, errHandoffExpired):
|
||||
http.Error(w, "handoff expired", http.StatusBadRequest)
|
||||
return
|
||||
case errors.Is(err, errHandoffReplay):
|
||||
http.Error(w, "handoff already used", http.StatusBadRequest)
|
||||
return
|
||||
case err != nil:
|
||||
http.Error(w, "invalid handoff", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
client, ok := h.ClientConfig[env.ClientID]
|
||||
if !ok {
|
||||
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
|
||||
Write(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !uriRegistered(client.RedirectURIs, env.RedirectURI) {
|
||||
profileerrors.RejectedForSafety(
|
||||
"handoff redirect_uri does not match the registered client",
|
||||
"redirect_uri",
|
||||
).Write(w, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
restart := url.Values{}
|
||||
restart.Set("client_id", env.ClientID)
|
||||
restart.Set("redirect_uri", env.RedirectURI)
|
||||
restart.Set("response_type", "code")
|
||||
restart.Set("scope", strings.Join(env.Scopes, " "))
|
||||
restart.Set("state", env.State)
|
||||
restart.Set("code_challenge", env.PKCEChallenge)
|
||||
restart.Set("code_challenge_method", env.PKCEChallengeMethod)
|
||||
if env.Nonce != "" {
|
||||
restart.Set("nonce", env.Nonce)
|
||||
}
|
||||
if env.TenantHint != "" {
|
||||
restart.Set("tenant_hint", env.TenantHint)
|
||||
}
|
||||
http.Redirect(w, r, "/authorize?"+restart.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) maybeEnrollmentHandoff(ctx context.Context, w http.ResponseWriter, r *http.Request, ps *PendingState, username string) (bool, error) {
|
||||
if !h.clientEligible(ps.ClientID, HandoffEnroll) {
|
||||
return false, nil
|
||||
}
|
||||
enrolled, err := h.MFA.HasEnrolledFactor(ctx, username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if enrolled {
|
||||
return false, nil
|
||||
}
|
||||
ps.AuthenticatedUser = username
|
||||
h.pending.Store(ps.State, ps)
|
||||
h.startHandoff(w, r, ps, HandoffEnroll)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) clientEligible(clientID string, kind HandoffKind) bool {
|
||||
client, ok := h.ClientConfig[clientID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch kind {
|
||||
case HandoffRegister:
|
||||
return client.RegistrationURL != ""
|
||||
case HandoffEnroll:
|
||||
return client.EnrollmentURL != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) renderUnknownUserSignup(w http.ResponseWriter, ps *PendingState) {
|
||||
clientName := ps.ClientID
|
||||
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.DisplayName != "" {
|
||||
clientName = client.DisplayName
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = unknownUserTemplate.Execute(w, struct {
|
||||
State string
|
||||
ClientName string
|
||||
}{
|
||||
State: ps.State,
|
||||
ClientName: clientName,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) {
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
|
|
@ -487,6 +702,63 @@ var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(`<!
|
|||
</body>
|
||||
</html>`))
|
||||
|
||||
var unknownUserTemplate = template.Must(template.New("unknown-user").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeyCape sign-in</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f6f7f9; color: #17202a; }
|
||||
main { width: min(420px, calc(100vw - 32px)); background: #fff; border: 1px solid #dfe4ea; border-radius: 8px; padding: 28px; }
|
||||
h1 { margin: 0 0 8px; font-size: 22px; }
|
||||
p { margin: 0 0 16px; color: #52606d; line-height: 1.45; }
|
||||
a { display: inline-block; height: 44px; line-height: 44px; padding: 0 16px; border-radius: 6px; background: #17324d; color: #fff; text-decoration: none; font-weight: 650; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Account not found</h1>
|
||||
<p>No KeyCape identity is available for this {{.ClientName}} sign-in. Create an account to continue. This does not issue a token.</p>
|
||||
<a href="/authorize/register?state={{.State}}">Create account</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
func parsePrompt(raw string) (create, login bool) {
|
||||
for _, part := range strings.Fields(raw) {
|
||||
switch strings.ToLower(part) {
|
||||
case "create":
|
||||
create = true
|
||||
case "login":
|
||||
login = true
|
||||
}
|
||||
}
|
||||
return create, login
|
||||
}
|
||||
|
||||
func parseMaxAge(raw string) (*time.Duration, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
secs, err := strconv.Atoi(raw)
|
||||
if err != nil || secs < 0 {
|
||||
return nil, errors.New("invalid max_age")
|
||||
}
|
||||
d := time.Duration(secs) * time.Second
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func uriRegistered(registered []string, target string) bool {
|
||||
for _, u := range registered {
|
||||
if u == target {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue