bootrapping support
This commit is contained in:
parent
393abf3e0e
commit
7e22fcf3c7
9 changed files with 715 additions and 23 deletions
|
|
@ -1,7 +1,10 @@
|
|||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -22,6 +25,7 @@ type PendingState struct {
|
|||
State string
|
||||
Scopes []string
|
||||
ExpiresAt time.Time
|
||||
AuthenticatedUser string
|
||||
}
|
||||
|
||||
// pendingStateStore is a thread-safe map of state → PendingState.
|
||||
|
|
@ -212,6 +216,17 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
|||
func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Request) {
|
||||
h.init()
|
||||
ctx := r.Context()
|
||||
|
||||
if r.Method == http.MethodPost {
|
||||
h.serveMFASubmission(w, r)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.Header().Set("Allow", "GET, POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
|
||||
state := q.Get("state")
|
||||
|
|
@ -229,7 +244,6 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
http.Error(w, "authorization request expired", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h.pending.Delete(state)
|
||||
|
||||
// Handle upstream callback.
|
||||
result, err := h.Auth.HandleCallback(ctx, domain.CallbackParams{
|
||||
|
|
@ -248,6 +262,19 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if result == nil || result.Username == "" {
|
||||
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.MFA.CheckMFARequired(ctx, result.Username)
|
||||
|
|
@ -256,34 +283,80 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
return
|
||||
}
|
||||
if mfaRequired {
|
||||
if mfaToken == "" {
|
||||
ps.AuthenticatedUser = result.Username
|
||||
h.pending.Store(state, ps)
|
||||
h.renderMFAChallenge(w, ps, "")
|
||||
return
|
||||
}
|
||||
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
EventType: telemetry.EventAuthFailure,
|
||||
ClientID: ps.ClientID,
|
||||
Endpoint: "/authorize/callback",
|
||||
Result: "failure",
|
||||
ErrorType: "mfa_failed",
|
||||
})
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) serveMFASubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
state := r.Form.Get("state")
|
||||
mfaToken := r.Form.Get("mfa_token")
|
||||
|
||||
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
|
||||
}
|
||||
if ps.AuthenticatedUser == "" {
|
||||
h.pending.Delete(state)
|
||||
http.Error(w, "mfa challenge not active", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(mfaToken) == "" {
|
||||
h.renderMFAChallenge(w, ps, "Enter the one-time code.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.MFA.ValidateMFAToken(ctx, ps.AuthenticatedUser, mfaToken); err != nil {
|
||||
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, ps.AuthenticatedUser)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.Request, ps *PendingState, username string) {
|
||||
// Generate authorization code and store PKCE session.
|
||||
sess := &PKCESession{
|
||||
ClientID: ps.ClientID,
|
||||
RedirectURI: ps.RedirectURI,
|
||||
PKCEChallenge: ps.PKCEChallenge,
|
||||
PKCEChallengeMethod: ps.PKCEChallengeMethod,
|
||||
State: state,
|
||||
Username: result.Username,
|
||||
State: ps.State,
|
||||
Username: username,
|
||||
Scopes: ps.Scopes,
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
}
|
||||
authCode := h.Sessions.Create(sess)
|
||||
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
h.Emitter.Emit(r.Context(), telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
EventType: telemetry.EventAuthSuccess,
|
||||
ClientID: ps.ClientID,
|
||||
|
|
@ -293,14 +366,94 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
|||
})
|
||||
|
||||
// Redirect to client with code and state.
|
||||
redirectTo := ps.RedirectURI + "?code=" + authCode + "&state=" + state
|
||||
http.Redirect(w, r, redirectTo, http.StatusFound)
|
||||
redirectTo, err := url.Parse(ps.RedirectURI)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid redirect_uri", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
q := redirectTo.Query()
|
||||
q.Set("code", authCode)
|
||||
q.Set("state", ps.State)
|
||||
redirectTo.RawQuery = q.Encode()
|
||||
http.Redirect(w, r, redirectTo.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) {
|
||||
h.Emitter.Emit(ctx, telemetry.Event{
|
||||
Timestamp: time.Now(),
|
||||
EventType: telemetry.EventAuthFailure,
|
||||
ClientID: clientID,
|
||||
Endpoint: "/authorize/callback",
|
||||
Result: "failure",
|
||||
ErrorType: "mfa_failed",
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthorizeHandler) renderMFAChallenge(w http.ResponseWriter, ps *PendingState, errorMessage string) {
|
||||
clientName := ps.ClientID
|
||||
if client, ok := h.ClientConfig[ps.ClientID]; ok && client.DisplayName != "" {
|
||||
clientName = client.DisplayName
|
||||
}
|
||||
status := http.StatusOK
|
||||
if errorMessage != "" {
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_ = mfaChallengeTemplate.Execute(w, struct {
|
||||
State string
|
||||
Username string
|
||||
ClientName string
|
||||
ErrorMessage string
|
||||
}{
|
||||
State: ps.State,
|
||||
Username: ps.AuthenticatedUser,
|
||||
ClientName: clientName,
|
||||
ErrorMessage: errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeyCape MFA</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe 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; box-shadow: 0 18px 45px rgba(23, 32, 42, .08); }
|
||||
h1 { margin: 0 0 6px; font-size: 22px; font-weight: 650; letter-spacing: 0; }
|
||||
p { margin: 0 0 20px; color: #52606d; line-height: 1.45; }
|
||||
label { display: block; margin: 0 0 8px; font-size: 13px; font-weight: 650; color: #344054; }
|
||||
input[type="text"] { width: 100%; box-sizing: border-box; height: 44px; border: 1px solid #c9d3df; border-radius: 6px; padding: 0 12px; font: inherit; background: #fff; }
|
||||
input[type="text"]:focus { outline: 2px solid #2f80ed; outline-offset: 2px; border-color: #2f80ed; }
|
||||
button { width: 100%; height: 44px; border: 0; border-radius: 6px; margin-top: 16px; background: #17324d; color: #fff; font: inherit; font-weight: 650; cursor: pointer; }
|
||||
button:focus { outline: 2px solid #2f80ed; outline-offset: 2px; }
|
||||
.meta { font-size: 13px; color: #667085; }
|
||||
.error { margin: 0 0 12px; color: #b42318; font-size: 13px; font-weight: 650; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Verify sign-in</h1>
|
||||
<p class="meta">{{.Username}} for {{.ClientName}}</p>
|
||||
{{if .ErrorMessage}}<p class="error">{{.ErrorMessage}}</p>{{end}}
|
||||
<form method="post" action="/authorize/callback" autocomplete="off">
|
||||
<input type="hidden" name="state" value="{{.State}}">
|
||||
<label for="mfa_token">One-time code</label>
|
||||
<input id="mfa_token" name="mfa_token" type="text" inputmode="numeric" autocomplete="one-time-code" required autofocus>
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
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