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
|
|
@ -53,3 +53,7 @@ clients:
|
||||||
allowedScopes: ["openid", "profile", "email", "groups"]
|
allowedScopes: ["openid", "profile", "email", "groups"]
|
||||||
grantTypes: ["authorization_code"]
|
grantTypes: ["authorization_code"]
|
||||||
clientType: "public"
|
clientType: "public"
|
||||||
|
# Ordinary login is AAL1; acr_values=aal2 still forces step-up.
|
||||||
|
# Other clients keep the provider default (mandatory MFA).
|
||||||
|
mfaRequired: false
|
||||||
|
registrationUrl: "https://users.92-205-62-239.nip.io/register"
|
||||||
|
|
|
||||||
|
|
@ -131,21 +131,33 @@ func main() {
|
||||||
TokenEndpoint: issuer + "/token",
|
TokenEndpoint: issuer + "/token",
|
||||||
JWKSUri: issuer + "/jwks",
|
JWKSUri: issuer + "/jwks",
|
||||||
UserinfoEndpoint: issuer + "/userinfo",
|
UserinfoEndpoint: issuer + "/userinfo",
|
||||||
|
EndSessionEndpoint: issuer + "/logout",
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// JWKS.
|
// JWKS.
|
||||||
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
|
mux.Handle("/jwks", oidc.NewJWKSHandler(ks))
|
||||||
|
|
||||||
// Authorize handler (with enforcement middleware).
|
// Authorize handler (with enforcement middleware).
|
||||||
|
logins := oidc.NewLoginSessionStore()
|
||||||
authorizeHandler := &oidc.AuthorizeHandler{
|
authorizeHandler := &oidc.AuthorizeHandler{
|
||||||
ClientConfig: clients,
|
ClientConfig: clients,
|
||||||
Auth: autheliaAdapter,
|
Auth: autheliaAdapter,
|
||||||
MFA: privacyIDEAAdapter,
|
MFA: privacyIDEAAdapter,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
Logins: logins,
|
||||||
|
Handoffs: oidc.NewHandoffStore(),
|
||||||
|
Issuer: issuer,
|
||||||
Emitter: emitter,
|
Emitter: emitter,
|
||||||
}
|
}
|
||||||
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
|
mux.Handle("/authorize", enforcement.Middleware(authorizeHandler))
|
||||||
mux.Handle("/authorize/callback", authorizeHandler)
|
mux.Handle("/authorize/callback", authorizeHandler)
|
||||||
|
mux.Handle("/authorize/return", authorizeHandler)
|
||||||
|
mux.Handle("/authorize/register", authorizeHandler)
|
||||||
|
mux.Handle("/logout", &oidc.LogoutHandler{
|
||||||
|
ClientConfig: clients,
|
||||||
|
Logins: logins,
|
||||||
|
SecureCookie: strings.HasPrefix(strings.ToLower(issuer), "https://"),
|
||||||
|
})
|
||||||
|
|
||||||
// Token handler (with enforcement middleware).
|
// Token handler (with enforcement middleware).
|
||||||
tokenHandler := &oidc.TokenHandler{
|
tokenHandler := &oidc.TokenHandler{
|
||||||
|
|
@ -272,8 +284,10 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
ServiceSubject: c.ServiceSubject,
|
ServiceSubject: c.ServiceSubject,
|
||||||
Tenant: c.Tenant,
|
Tenant: c.Tenant,
|
||||||
Roles: c.Roles,
|
Roles: c.Roles,
|
||||||
MFARequired: c.MFARequired,
|
MFARequired: c.MFARequired,
|
||||||
|
RegistrationURL: c.RegistrationURL,
|
||||||
|
EnrollmentURL: c.EnrollmentURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,17 @@ func (a *PrivacyIDEAAdapter) CheckMFARequired(ctx context.Context, userID string
|
||||||
if a.cfg.RequireForAll {
|
if a.cfg.RequireForAll {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
return a.hasActiveToken(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasEnrolledFactor reports whether privacyIDEA has an active token for the
|
||||||
|
// user. RequireForAll does not skip this check — enrollment is independent
|
||||||
|
// of the global require-for-all policy.
|
||||||
|
func (a *PrivacyIDEAAdapter) HasEnrolledFactor(ctx context.Context, userID string) (bool, error) {
|
||||||
|
return a.hasActiveToken(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *PrivacyIDEAAdapter) hasActiveToken(ctx context.Context, userID string) (bool, error) {
|
||||||
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
|
endpoint := strings.TrimRight(a.cfg.BaseURL, "/") + "/token/"
|
||||||
|
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,48 @@ func TestCheckMFARequired_InactiveTokenOnly_ReturnsFalse(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHasEnrolledFactor_RequireForAllStillListsTokens(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
client := &mockHTTPClient{
|
||||||
|
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||||
|
called = true
|
||||||
|
return jsonResponse(tokenListResponse(nil)), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.RequireForAll = true
|
||||||
|
adapter := privacyidea.New(cfg, client)
|
||||||
|
|
||||||
|
enrolled, err := adapter.HasEnrolledFactor(context.Background(), "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if enrolled {
|
||||||
|
t.Error("expected enrolled=false when no tokens even if RequireForAll")
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Error("HasEnrolledFactor must consult the token list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasEnrolledFactor_ActiveToken_ReturnsTrue(t *testing.T) {
|
||||||
|
client := &mockHTTPClient{
|
||||||
|
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||||
|
return jsonResponse(tokenListResponse([]map[string]interface{}{
|
||||||
|
{"active": true},
|
||||||
|
})), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
adapter := privacyidea.New(testConfig(), client)
|
||||||
|
enrolled, err := adapter.HasEnrolledFactor(context.Background(), "alice")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !enrolled {
|
||||||
|
t.Error("expected enrolled=true when an active token is present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckMFARequired_NoTokens_ReturnsFalse(t *testing.T) {
|
func TestCheckMFARequired_NoTokens_ReturnsFalse(t *testing.T) {
|
||||||
client := &mockHTTPClient{
|
client := &mockHTTPClient{
|
||||||
doFn: func(_ *http.Request) (*http.Response, error) {
|
doFn: func(_ *http.Request) (*http.Response, error) {
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,10 @@ type ClientConfig struct {
|
||||||
SecretRef string `yaml:"secretRef,omitempty"`
|
SecretRef string `yaml:"secretRef,omitempty"`
|
||||||
ServiceSubject string `yaml:"serviceSubject,omitempty"`
|
ServiceSubject string `yaml:"serviceSubject,omitempty"`
|
||||||
Tenant string `yaml:"tenant,omitempty"`
|
Tenant string `yaml:"tenant,omitempty"`
|
||||||
Roles []string `yaml:"roles,omitempty"`
|
Roles []string `yaml:"roles,omitempty"`
|
||||||
MFARequired *bool `yaml:"mfaRequired,omitempty"`
|
MFARequired *bool `yaml:"mfaRequired,omitempty"`
|
||||||
|
RegistrationURL string `yaml:"registrationUrl,omitempty"`
|
||||||
|
EnrollmentURL string `yaml:"enrollmentUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads and parses the YAML config file at path.
|
// Load reads and parses the YAML config file at path.
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,50 @@ clients:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoad_ClientMFAAndRegistrationURL(t *testing.T) {
|
||||||
|
keyPath := writeTempFile(t, "placeholder-key")
|
||||||
|
yaml := `
|
||||||
|
issuer: "https://kc.example.com"
|
||||||
|
port: 8080
|
||||||
|
tokenLifetime: "15m"
|
||||||
|
privateKeyPem: "` + keyPath + `"
|
||||||
|
environment: "dev"
|
||||||
|
clients:
|
||||||
|
- clientId: "coulomb-social"
|
||||||
|
displayName: "coulomb.social"
|
||||||
|
redirectUris:
|
||||||
|
- "https://coulomb.social/auth/callback/"
|
||||||
|
clientType: "public"
|
||||||
|
mfaRequired: false
|
||||||
|
registrationUrl: "https://users.example.com/register"
|
||||||
|
`
|
||||||
|
cfgPath := writeTempFile(t, yaml)
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Clients) != 1 {
|
||||||
|
t.Fatalf("clients: got %d", len(cfg.Clients))
|
||||||
|
}
|
||||||
|
c := cfg.Clients[0]
|
||||||
|
if c.MFARequired == nil || *c.MFARequired {
|
||||||
|
t.Fatalf("mfaRequired: want false, got %+v", c.MFARequired)
|
||||||
|
}
|
||||||
|
if c.RegistrationURL != "https://users.example.com/register" {
|
||||||
|
t.Errorf("registrationUrl: got %q", c.RegistrationURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_InvalidRegistrationURL(t *testing.T) {
|
||||||
|
keyPath := writeTempFile(t, "key")
|
||||||
|
cfg := validConfig(keyPath)
|
||||||
|
cfg.Clients[0].RegistrationURL = "javascript:alert(1)"
|
||||||
|
errs := config.ValidateConfig(cfg)
|
||||||
|
if !containsErr(errs, "registrationUrl") {
|
||||||
|
t.Errorf("expected registrationUrl error, got %v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoad_PrivacyIDEARequireForAll(t *testing.T) {
|
func TestLoad_PrivacyIDEARequireForAll(t *testing.T) {
|
||||||
keyPath := writeTempFile(t, "placeholder-key")
|
keyPath := writeTempFile(t, "placeholder-key")
|
||||||
yaml := `
|
yaml := `
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,16 @@ func ValidateConfig(cfg *Config) []string {
|
||||||
errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri))
|
errs = append(errs, prefix+fmt.Sprintf(": redirect_uri %q must not contain wildcards", uri))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.RegistrationURL != "" {
|
||||||
|
if err := validateHandoffURL(c.RegistrationURL); err != nil {
|
||||||
|
errs = append(errs, prefix+": registrationUrl: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.EnrollmentURL != "" {
|
||||||
|
if err := validateHandoffURL(c.EnrollmentURL); err != nil {
|
||||||
|
errs = append(errs, prefix+": enrollmentUrl: "+err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Private key PEM path must be provided (existence is checked at startup).
|
// Private key PEM path must be provided (existence is checked at startup).
|
||||||
|
|
@ -81,3 +91,20 @@ func contains(values []string, wanted string) bool {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateHandoffURL(raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return fmt.Errorf("%q is not an absolute URL", raw)
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return fmt.Errorf("%q scheme must be http or https", raw)
|
||||||
|
}
|
||||||
|
if u.User != nil {
|
||||||
|
return fmt.Errorf("%q must not contain userinfo", raw)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(u.Host, "*?") || strings.ContainsAny(u.Path, "*") {
|
||||||
|
return fmt.Errorf("%q must not contain wildcards", raw)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
116
src/internal/domain/assurance.go
Normal file
116
src/internal/domain/assurance.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssuranceLevel is the NetKingdom IAM Profile authentication assurance
|
||||||
|
// level required or satisfied for a request.
|
||||||
|
type AssuranceLevel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AssuranceNone means no KeyCape login session is present.
|
||||||
|
AssuranceNone AssuranceLevel = 0
|
||||||
|
// AssuranceAAL1 is password (or equivalent single-factor) assurance.
|
||||||
|
AssuranceAAL1 AssuranceLevel = 1
|
||||||
|
// AssuranceAAL2 is MFA or equivalent strong assurance.
|
||||||
|
AssuranceAAL2 AssuranceLevel = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssuranceInput is the evidence DecideAssurance combines. Client override,
|
||||||
|
// requested ACR, provider default, and current session are evaluated for
|
||||||
|
// the current request only — never for another client.
|
||||||
|
type AssuranceInput struct {
|
||||||
|
Client *Client
|
||||||
|
ACRValues []string
|
||||||
|
ProviderRequired bool
|
||||||
|
SessionLevel AssuranceLevel
|
||||||
|
SessionUser string
|
||||||
|
RequestUser string
|
||||||
|
SessionIssuedAt time.Time
|
||||||
|
Now time.Time
|
||||||
|
MaxAge *time.Duration
|
||||||
|
PromptLogin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssuranceDecision is the per-request MFA/session outcome.
|
||||||
|
type AssuranceDecision struct {
|
||||||
|
RequiredLevel AssuranceLevel
|
||||||
|
RequireMFA bool
|
||||||
|
SessionSatisfies bool
|
||||||
|
MFAVerified bool
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACRRequiresAAL2 reports whether requested acr_values ask for step-up.
|
||||||
|
func ACRRequiresAAL2(acrValues []string) bool {
|
||||||
|
for _, acr := range acrValues {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(acr)) {
|
||||||
|
case "aal2", "mfa", "urn:netkingdom:aal2":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecideAssurance combines client minimum assurance, requested ACR/step-up,
|
||||||
|
// provider/tenant default, and current session assurance. ACR can only raise
|
||||||
|
// the requirement. A client override applies only to that client. An AAL1
|
||||||
|
// session cannot satisfy an AAL2 request.
|
||||||
|
func DecideAssurance(in AssuranceInput) AssuranceDecision {
|
||||||
|
required, source := requiredLevel(in)
|
||||||
|
decision := AssuranceDecision{
|
||||||
|
RequiredLevel: required,
|
||||||
|
Source: source,
|
||||||
|
}
|
||||||
|
|
||||||
|
if sessionUsable(in) && in.SessionLevel >= required {
|
||||||
|
decision.SessionSatisfies = true
|
||||||
|
decision.RequireMFA = false
|
||||||
|
decision.MFAVerified = in.SessionLevel >= AssuranceAAL2
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
decision.RequireMFA = required >= AssuranceAAL2
|
||||||
|
decision.MFAVerified = false
|
||||||
|
return decision
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredLevel(in AssuranceInput) (AssuranceLevel, string) {
|
||||||
|
if ACRRequiresAAL2(in.ACRValues) {
|
||||||
|
return AssuranceAAL2, "acr"
|
||||||
|
}
|
||||||
|
if in.Client != nil && in.Client.MFARequired != nil {
|
||||||
|
if *in.Client.MFARequired {
|
||||||
|
return AssuranceAAL2, "client"
|
||||||
|
}
|
||||||
|
return AssuranceAAL1, "client"
|
||||||
|
}
|
||||||
|
if in.ProviderRequired {
|
||||||
|
return AssuranceAAL2, "provider"
|
||||||
|
}
|
||||||
|
return AssuranceAAL1, "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionUsable(in AssuranceInput) bool {
|
||||||
|
if in.PromptLogin {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if in.SessionLevel == AssuranceNone {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if in.RequestUser != "" && in.SessionUser != "" && in.SessionUser != in.RequestUser {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := in.Now
|
||||||
|
if now.IsZero() {
|
||||||
|
now = time.Now()
|
||||||
|
}
|
||||||
|
if in.MaxAge != nil {
|
||||||
|
if in.SessionIssuedAt.IsZero() || now.Sub(in.SessionIssuedAt) > *in.MaxAge {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
113
src/internal/domain/assurance_test.go
Normal file
113
src/internal/domain/assurance_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool { return &v }
|
||||||
|
|
||||||
|
func TestDecideAssurance_ClientOverrideIsPerClient(t *testing.T) {
|
||||||
|
low := &Client{ClientID: "coulomb-social", MFARequired: boolPtr(false)}
|
||||||
|
high := &Client{ClientID: "openbao-console"}
|
||||||
|
|
||||||
|
lowDec := DecideAssurance(AssuranceInput{Client: low, ProviderRequired: true})
|
||||||
|
if lowDec.RequireMFA || lowDec.RequiredLevel != AssuranceAAL1 || lowDec.Source != "client" {
|
||||||
|
t.Fatalf("low-assurance client: %+v", lowDec)
|
||||||
|
}
|
||||||
|
|
||||||
|
highDec := DecideAssurance(AssuranceInput{Client: high, ProviderRequired: true})
|
||||||
|
if !highDec.RequireMFA || highDec.RequiredLevel != AssuranceAAL2 || highDec.Source != "provider" {
|
||||||
|
t.Fatalf("high-assurance client must keep provider MFA: %+v", highDec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAssurance_ACRRaisesClientAAL1(t *testing.T) {
|
||||||
|
client := &Client{ClientID: "coulomb-social", MFARequired: boolPtr(false)}
|
||||||
|
dec := DecideAssurance(AssuranceInput{
|
||||||
|
Client: client,
|
||||||
|
ACRValues: []string{"aal2"},
|
||||||
|
})
|
||||||
|
if !dec.RequireMFA || dec.Source != "acr" {
|
||||||
|
t.Fatalf("acr must raise AAL1 client: %+v", dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAssurance_AAL1SessionCannotSatisfyAAL2(t *testing.T) {
|
||||||
|
high := &Client{ClientID: "openbao-console"}
|
||||||
|
dec := DecideAssurance(AssuranceInput{
|
||||||
|
Client: high,
|
||||||
|
ProviderRequired: true,
|
||||||
|
SessionLevel: AssuranceAAL1,
|
||||||
|
SessionUser: "alice",
|
||||||
|
RequestUser: "alice",
|
||||||
|
})
|
||||||
|
if dec.SessionSatisfies || !dec.RequireMFA || dec.MFAVerified {
|
||||||
|
t.Fatalf("AAL1 session must not satisfy AAL2: %+v", dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAssurance_AAL2SessionSatisfiesHighAssurance(t *testing.T) {
|
||||||
|
high := &Client{ClientID: "openbao-console"}
|
||||||
|
dec := DecideAssurance(AssuranceInput{
|
||||||
|
Client: high,
|
||||||
|
ProviderRequired: true,
|
||||||
|
SessionLevel: AssuranceAAL2,
|
||||||
|
SessionUser: "alice",
|
||||||
|
RequestUser: "alice",
|
||||||
|
})
|
||||||
|
if !dec.SessionSatisfies || dec.RequireMFA || !dec.MFAVerified {
|
||||||
|
t.Fatalf("AAL2 session should satisfy AAL2: %+v", dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAssurance_MaxAgeInvalidatesSession(t *testing.T) {
|
||||||
|
maxAge := 30 * time.Second
|
||||||
|
now := time.Unix(1_700_000_100, 0)
|
||||||
|
dec := DecideAssurance(AssuranceInput{
|
||||||
|
Client: &Client{ClientID: "coulomb-social", MFARequired: boolPtr(false)},
|
||||||
|
SessionLevel: AssuranceAAL1,
|
||||||
|
SessionUser: "alice",
|
||||||
|
RequestUser: "alice",
|
||||||
|
SessionIssuedAt: now.Add(-time.Minute),
|
||||||
|
Now: now,
|
||||||
|
MaxAge: &maxAge,
|
||||||
|
})
|
||||||
|
if dec.SessionSatisfies {
|
||||||
|
t.Fatalf("expired max_age session must not satisfy: %+v", dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAssurance_PromptLoginIgnoresSession(t *testing.T) {
|
||||||
|
dec := DecideAssurance(AssuranceInput{
|
||||||
|
Client: &Client{ClientID: "coulomb-social", MFARequired: boolPtr(false)},
|
||||||
|
SessionLevel: AssuranceAAL2,
|
||||||
|
SessionUser: "alice",
|
||||||
|
RequestUser: "alice",
|
||||||
|
PromptLogin: true,
|
||||||
|
})
|
||||||
|
if dec.SessionSatisfies {
|
||||||
|
t.Fatalf("prompt=login must ignore session: %+v", dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideAssurance_SessionUserMismatchIgnored(t *testing.T) {
|
||||||
|
dec := DecideAssurance(AssuranceInput{
|
||||||
|
ProviderRequired: true,
|
||||||
|
SessionLevel: AssuranceAAL2,
|
||||||
|
SessionUser: "alice",
|
||||||
|
RequestUser: "bob",
|
||||||
|
})
|
||||||
|
if dec.SessionSatisfies || !dec.RequireMFA {
|
||||||
|
t.Fatalf("foreign session must not satisfy: %+v", dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestACRRequiresAAL2(t *testing.T) {
|
||||||
|
if !ACRRequiresAAL2([]string{"urn:netkingdom:aal2"}) {
|
||||||
|
t.Fatal("expected urn:netkingdom:aal2 to require AAL2")
|
||||||
|
}
|
||||||
|
if ACRRequiresAAL2([]string{"aal1"}) {
|
||||||
|
t.Fatal("aal1 must not require AAL2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,11 @@ type MFAProvider interface {
|
||||||
// CheckMFARequired returns true if MFA is required for the given user.
|
// CheckMFARequired returns true if MFA is required for the given user.
|
||||||
CheckMFARequired(ctx context.Context, userID string) (bool, error)
|
CheckMFARequired(ctx context.Context, userID string) (bool, error)
|
||||||
|
|
||||||
|
// HasEnrolledFactor reports whether the user has at least one active
|
||||||
|
// factor. Distinct from CheckMFARequired: a provider-wide require-for-all
|
||||||
|
// policy can demand MFA even when the user has not enrolled yet.
|
||||||
|
HasEnrolledFactor(ctx context.Context, userID string) (bool, error)
|
||||||
|
|
||||||
// ValidateMFAToken validates the given OTP token for the user.
|
// ValidateMFAToken validates the given OTP token for the user.
|
||||||
// Returns ErrMFAFailed if the token is invalid or expired.
|
// Returns ErrMFAFailed if the token is invalid or expired.
|
||||||
ValidateMFAToken(ctx context.Context, userID, token string) error
|
ValidateMFAToken(ctx context.Context, userID, token string) error
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,10 @@ type Client struct {
|
||||||
ClientSecret string `yaml:"-" json:"-"`
|
ClientSecret string `yaml:"-" json:"-"`
|
||||||
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
|
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
|
||||||
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
|
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
|
||||||
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
|
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
|
||||||
MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"`
|
MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"`
|
||||||
|
RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"`
|
||||||
|
EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Membership links a user to a group.
|
// Membership links a user to a group.
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ package oidc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -28,6 +30,9 @@ type PendingState struct {
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
AuthenticatedUser string
|
AuthenticatedUser string
|
||||||
ACRValues []string
|
ACRValues []string
|
||||||
|
TenantHint string
|
||||||
|
MaxAge *time.Duration
|
||||||
|
PromptLogin bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// pendingStateStore is a thread-safe map of state → PendingState.
|
// pendingStateStore is a thread-safe map of state → PendingState.
|
||||||
|
|
@ -65,6 +70,9 @@ type AuthorizeHandler struct {
|
||||||
Auth domain.AuthProvider
|
Auth domain.AuthProvider
|
||||||
MFA domain.MFAProvider
|
MFA domain.MFAProvider
|
||||||
Sessions *SessionStore
|
Sessions *SessionStore
|
||||||
|
Logins *LoginSessionStore
|
||||||
|
Handoffs *HandoffStore
|
||||||
|
Issuer string
|
||||||
Emitter telemetry.Emitter
|
Emitter telemetry.Emitter
|
||||||
|
|
||||||
pending *pendingStateStore
|
pending *pendingStateStore
|
||||||
|
|
@ -82,17 +90,28 @@ func (h *AuthorizeHandler) init() {
|
||||||
if h.pending == nil {
|
if h.pending == nil {
|
||||||
h.pending = newPendingStateStore()
|
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.
|
// ServeHTTP dispatches to the authorize or callback handler based on path.
|
||||||
func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *AuthorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
h.init()
|
h.init()
|
||||||
if strings.HasSuffix(r.URL.Path, "/callback") {
|
switch {
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/callback"):
|
||||||
h.ServeHTTPCallback(w, r)
|
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.
|
// 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")
|
codeChallenge := q.Get("code_challenge")
|
||||||
codeChallengeMethod := q.Get("code_challenge_method")
|
codeChallengeMethod := q.Get("code_challenge_method")
|
||||||
acrValues := strings.Fields(q.Get("acr_values"))
|
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.
|
// Emit auth_start telemetry immediately.
|
||||||
h.Emitter.Emit(ctx, telemetry.Event{
|
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.
|
// Store pending state so the callback can reconstruct the session.
|
||||||
h.pending.Store(state, &PendingState{
|
ps := &PendingState{
|
||||||
ClientID: clientID,
|
ClientID: clientID,
|
||||||
RedirectURI: redirectURI,
|
RedirectURI: redirectURI,
|
||||||
PKCEChallenge: codeChallenge,
|
PKCEChallenge: codeChallenge,
|
||||||
|
|
@ -198,8 +225,17 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
|
||||||
Nonce: nonce,
|
Nonce: nonce,
|
||||||
Scopes: strings.Fields(scope),
|
Scopes: strings.Fields(scope),
|
||||||
ACRValues: acrValues,
|
ACRValues: acrValues,
|
||||||
|
TenantHint: tenantHint,
|
||||||
|
MaxAge: maxAge,
|
||||||
|
PromptLogin: promptLogin,
|
||||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
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.
|
// Delegate to Auth provider.
|
||||||
authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{
|
authURL, err := h.Auth.AuthorizeURL(ctx, domain.AuthRequest{
|
||||||
|
|
@ -256,7 +292,7 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
||||||
Code: code,
|
Code: code,
|
||||||
State: state,
|
State: state,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil || result == nil || result.Username == "" {
|
||||||
h.Emitter.Emit(ctx, telemetry.Event{
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
EventType: telemetry.EventAuthFailure,
|
EventType: telemetry.EventAuthFailure,
|
||||||
|
|
@ -265,25 +301,16 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
||||||
Result: "failure",
|
Result: "failure",
|
||||||
ErrorType: "auth_failed",
|
ErrorType: "auth_failed",
|
||||||
})
|
})
|
||||||
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
if h.clientEligible(ps.ClientID, HandoffRegister) {
|
||||||
return
|
h.renderUnknownUserSignup(w, ps)
|
||||||
}
|
return
|
||||||
if result == nil || result.Username == "" {
|
}
|
||||||
h.pending.Delete(state)
|
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)
|
http.Error(w, "authentication failed", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check MFA requirement.
|
decision, err := h.decideAssurance(ctx, ps, result.Username, h.Logins.fromRequest(r))
|
||||||
mfaRequired, _, err := h.mfaRequirement(ps, result.Username)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.Emitter.Emit(ctx, telemetry.Event{
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
Timestamp: time.Now(),
|
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)
|
http.Error(w, "mfa check error", http.StatusInternalServerError)
|
||||||
return
|
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 == "" {
|
if mfaToken == "" {
|
||||||
ps.AuthenticatedUser = result.Username
|
ps.AuthenticatedUser = result.Username
|
||||||
h.pending.Store(state, ps)
|
h.pending.Store(state, ps)
|
||||||
|
|
@ -304,29 +337,52 @@ func (h *AuthorizeHandler) ServeHTTPCallback(w http.ResponseWriter, r *http.Requ
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.MFA.ValidateMFAToken(ctx, result.Username, mfaToken); err != nil {
|
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.pending.Delete(state)
|
||||||
h.emitMFAFailure(ctx, ps.ClientID)
|
h.emitMFAFailure(ctx, ps.ClientID)
|
||||||
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
http.Error(w, "MFA validation failed", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.pending.Delete(state)
|
||||||
|
h.completeAuthorization(w, r, ps, result.Username, true)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.pending.Delete(state)
|
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) {
|
func (h *AuthorizeHandler) decideAssurance(ctx context.Context, ps *PendingState, username string, login *LoginSession) (domain.AssuranceDecision, error) {
|
||||||
for _, acr := range ps.ACRValues {
|
client := h.ClientConfig[ps.ClientID]
|
||||||
switch strings.ToLower(acr) {
|
providerRequired := false
|
||||||
case "aal2", "mfa", "urn:netkingdom:aal2":
|
if !domain.ACRRequiresAAL2(ps.ACRValues) && (client == nil || client.MFARequired == nil) {
|
||||||
return true, false, 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 {
|
in := domain.AssuranceInput{
|
||||||
return *client.MFARequired, false, nil
|
Client: client,
|
||||||
|
ACRValues: ps.ACRValues,
|
||||||
|
ProviderRequired: providerRequired,
|
||||||
|
RequestUser: username,
|
||||||
|
PromptLogin: ps.PromptLogin,
|
||||||
|
MaxAge: ps.MaxAge,
|
||||||
}
|
}
|
||||||
required, err := h.MFA.CheckMFARequired(context.Background(), username)
|
if login != nil {
|
||||||
return required, true, err
|
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) {
|
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) {
|
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.
|
// Generate authorization code and store PKCE session.
|
||||||
sess := &PKCESession{
|
sess := &PKCESession{
|
||||||
ClientID: ps.ClientID,
|
ClientID: ps.ClientID,
|
||||||
|
|
@ -411,6 +475,157 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http.
|
||||||
http.Redirect(w, r, redirectTo.String(), http.StatusFound)
|
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) {
|
func (h *AuthorizeHandler) emitMFAFailure(ctx context.Context, clientID string) {
|
||||||
h.Emitter.Emit(ctx, telemetry.Event{
|
h.Emitter.Emit(ctx, telemetry.Event{
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
|
|
@ -487,6 +702,63 @@ var mfaChallengeTemplate = template.Must(template.New("mfa-challenge").Parse(`<!
|
||||||
</body>
|
</body>
|
||||||
</html>`))
|
</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 {
|
func uriRegistered(registered []string, target string) bool {
|
||||||
for _, u := range registered {
|
for _, u := range registered {
|
||||||
if u == target {
|
if u == target {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ func (m *mockAuthProvider) HandleCallback(_ context.Context, _ domain.CallbackPa
|
||||||
type mockMFAProvider struct {
|
type mockMFAProvider struct {
|
||||||
required bool
|
required bool
|
||||||
requiredErr error
|
requiredErr error
|
||||||
|
enrolled bool
|
||||||
|
enrolledErr error
|
||||||
|
|
||||||
validateErr error
|
validateErr error
|
||||||
validateCalls int
|
validateCalls int
|
||||||
|
|
@ -55,6 +57,10 @@ func (m *mockMFAProvider) CheckMFARequired(_ context.Context, _ string) (bool, e
|
||||||
return m.required, m.requiredErr
|
return m.required, m.requiredErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockMFAProvider) HasEnrolledFactor(_ context.Context, _ string) (bool, error) {
|
||||||
|
return m.enrolled, m.enrolledErr
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, user, token string) error {
|
func (m *mockMFAProvider) ValidateMFAToken(_ context.Context, user, token string) error {
|
||||||
m.validateCalls++
|
m.validateCalls++
|
||||||
m.validatedUser = user
|
m.validatedUser = user
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ type DiscoveryConfig struct {
|
||||||
TokenEndpoint string
|
TokenEndpoint string
|
||||||
JWKSUri string
|
JWKSUri string
|
||||||
UserinfoEndpoint string // optional, empty = not advertised
|
UserinfoEndpoint string // optional, empty = not advertised
|
||||||
|
EndSessionEndpoint string // optional, empty = not advertised
|
||||||
}
|
}
|
||||||
|
|
||||||
// discoveryDocument is the JSON shape of /.well-known/openid-configuration.
|
// discoveryDocument is the JSON shape of /.well-known/openid-configuration.
|
||||||
|
|
@ -27,6 +28,7 @@ type discoveryDocument struct {
|
||||||
TokenEndpoint string `json:"token_endpoint"`
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
JWKSUri string `json:"jwks_uri"`
|
JWKSUri string `json:"jwks_uri"`
|
||||||
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
|
UserinfoEndpoint string `json:"userinfo_endpoint,omitempty"`
|
||||||
|
EndSessionEndpoint string `json:"end_session_endpoint,omitempty"`
|
||||||
ResponseTypesSupported []string `json:"response_types_supported"`
|
ResponseTypesSupported []string `json:"response_types_supported"`
|
||||||
GrantTypesSupported []string `json:"grant_types_supported"`
|
GrantTypesSupported []string `json:"grant_types_supported"`
|
||||||
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
|
||||||
|
|
@ -53,6 +55,7 @@ func NewDiscoveryHandler(cfg DiscoveryConfig) http.Handler {
|
||||||
TokenEndpoint: cfg.TokenEndpoint,
|
TokenEndpoint: cfg.TokenEndpoint,
|
||||||
JWKSUri: cfg.JWKSUri,
|
JWKSUri: cfg.JWKSUri,
|
||||||
UserinfoEndpoint: cfg.UserinfoEndpoint,
|
UserinfoEndpoint: cfg.UserinfoEndpoint,
|
||||||
|
EndSessionEndpoint: cfg.EndSessionEndpoint,
|
||||||
|
|
||||||
// Profile-locked values — not negotiable.
|
// Profile-locked values — not negotiable.
|
||||||
ResponseTypesSupported: []string{"code"},
|
ResponseTypesSupported: []string{"code"},
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,33 @@ func TestDiscoveryHandler_Endpoints(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiscoveryHandler_EndSessionAdvertisedWhenConfigured(t *testing.T) {
|
||||||
|
cfg := oidc.DiscoveryConfig{
|
||||||
|
Issuer: "https://auth.netkingdom.local",
|
||||||
|
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
|
||||||
|
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
|
||||||
|
JWKSUri: "https://auth.netkingdom.local/jwks",
|
||||||
|
EndSessionEndpoint: "https://auth.netkingdom.local/logout",
|
||||||
|
}
|
||||||
|
doc := discoveryDoc(t, cfg)
|
||||||
|
if doc["end_session_endpoint"] != cfg.EndSessionEndpoint {
|
||||||
|
t.Errorf("end_session_endpoint: expected %q, got %v", cfg.EndSessionEndpoint, doc["end_session_endpoint"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiscoveryHandler_EndSessionOmittedWhenEmpty(t *testing.T) {
|
||||||
|
cfg := oidc.DiscoveryConfig{
|
||||||
|
Issuer: "https://auth.netkingdom.local",
|
||||||
|
AuthorizationEndpoint: "https://auth.netkingdom.local/oauth2/authorize",
|
||||||
|
TokenEndpoint: "https://auth.netkingdom.local/oauth2/token",
|
||||||
|
JWKSUri: "https://auth.netkingdom.local/jwks",
|
||||||
|
}
|
||||||
|
doc := discoveryDoc(t, cfg)
|
||||||
|
if _, ok := doc["end_session_endpoint"]; ok {
|
||||||
|
t.Error("end_session_endpoint must be absent when not configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDiscoveryHandler_UserinfoOmittedWhenEmpty(t *testing.T) {
|
func TestDiscoveryHandler_UserinfoOmittedWhenEmpty(t *testing.T) {
|
||||||
cfg := oidc.DiscoveryConfig{
|
cfg := oidc.DiscoveryConfig{
|
||||||
Issuer: "https://auth.netkingdom.local",
|
Issuer: "https://auth.netkingdom.local",
|
||||||
|
|
|
||||||
183
src/internal/server/oidc/handoff.go
Normal file
183
src/internal/server/oidc/handoff.go
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandoffKind distinguishes registration from MFA-enrollment return envelopes.
|
||||||
|
type HandoffKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
HandoffRegister HandoffKind = "register"
|
||||||
|
HandoffEnroll HandoffKind = "enroll"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultHandoffTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// HandoffEnvelope is the signed, expiring state carried to an allow-listed
|
||||||
|
// registration or enrollment URL. Completing a handoff restarts /authorize
|
||||||
|
// and never mints a token.
|
||||||
|
type HandoffEnvelope struct {
|
||||||
|
Kind HandoffKind `json:"kind"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
RedirectURI string `json:"redirect_uri"`
|
||||||
|
PKCEChallenge string `json:"code_challenge"`
|
||||||
|
PKCEChallengeMethod string `json:"code_challenge_method"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Nonce string `json:"nonce,omitempty"`
|
||||||
|
Scopes []string `json:"scopes,omitempty"`
|
||||||
|
TenantHint string `json:"tenant_hint,omitempty"`
|
||||||
|
JTI string `json:"jti"`
|
||||||
|
ExpiresAt time.Time `json:"exp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errHandoffInvalid = errors.New("invalid handoff")
|
||||||
|
errHandoffExpired = errors.New("handoff expired")
|
||||||
|
errHandoffReplay = errors.New("handoff replayed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandoffStore signs and atomically consumes registration/enrollment envelopes.
|
||||||
|
type HandoffStore struct {
|
||||||
|
secret []byte
|
||||||
|
ttl time.Duration
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
consumed map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandoffStore returns a store with an ephemeral HMAC key. Envelopes are
|
||||||
|
// short-lived, so a process restart simply invalidates in-flight handoffs.
|
||||||
|
func NewHandoffStore() *HandoffStore {
|
||||||
|
secret := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(secret); err != nil {
|
||||||
|
panic("oidc: failed to generate handoff secret: " + err.Error())
|
||||||
|
}
|
||||||
|
return &HandoffStore{
|
||||||
|
secret: secret,
|
||||||
|
ttl: defaultHandoffTTL,
|
||||||
|
consumed: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue signs a new envelope for the given pending authorization.
|
||||||
|
func (s *HandoffStore) Issue(kind HandoffKind, ps *PendingState) (string, error) {
|
||||||
|
if s == nil {
|
||||||
|
return "", errHandoffInvalid
|
||||||
|
}
|
||||||
|
jti, err := randomID()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
env := HandoffEnvelope{
|
||||||
|
Kind: kind,
|
||||||
|
ClientID: ps.ClientID,
|
||||||
|
RedirectURI: ps.RedirectURI,
|
||||||
|
PKCEChallenge: ps.PKCEChallenge,
|
||||||
|
PKCEChallengeMethod: ps.PKCEChallengeMethod,
|
||||||
|
State: ps.State,
|
||||||
|
Nonce: ps.Nonce,
|
||||||
|
Scopes: append([]string(nil), ps.Scopes...),
|
||||||
|
TenantHint: ps.TenantHint,
|
||||||
|
JTI: jti,
|
||||||
|
ExpiresAt: time.Now().Add(s.ttl),
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, s.secret)
|
||||||
|
mac.Write(payload)
|
||||||
|
token := base64.RawURLEncoding.EncodeToString(payload) + "." +
|
||||||
|
base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume verifies the envelope, rejects expiry/tamper/replay, and marks the
|
||||||
|
// JTI used. The caller must restart /authorize; it must not mint a token.
|
||||||
|
func (s *HandoffStore) Consume(token string) (*HandoffEnvelope, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, errHandoffInvalid
|
||||||
|
}
|
||||||
|
payload, err := s.verify(token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var env HandoffEnvelope
|
||||||
|
if err := json.Unmarshal(payload, &env); err != nil {
|
||||||
|
return nil, errHandoffInvalid
|
||||||
|
}
|
||||||
|
if env.JTI == "" || env.ClientID == "" || env.RedirectURI == "" {
|
||||||
|
return nil, errHandoffInvalid
|
||||||
|
}
|
||||||
|
if time.Now().After(env.ExpiresAt) {
|
||||||
|
return nil, errHandoffExpired
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.gcLocked()
|
||||||
|
if _, used := s.consumed[env.JTI]; used {
|
||||||
|
return nil, errHandoffReplay
|
||||||
|
}
|
||||||
|
s.consumed[env.JTI] = env.ExpiresAt
|
||||||
|
return &env, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HandoffStore) verify(token string) ([]byte, error) {
|
||||||
|
dot := strings.LastIndex(token, ".")
|
||||||
|
if dot <= 0 || dot == len(token)-1 {
|
||||||
|
return nil, errHandoffInvalid
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(token[:dot])
|
||||||
|
if err != nil {
|
||||||
|
return nil, errHandoffInvalid
|
||||||
|
}
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(token[dot+1:])
|
||||||
|
if err != nil {
|
||||||
|
return nil, errHandoffInvalid
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, s.secret)
|
||||||
|
mac.Write(payload)
|
||||||
|
if !hmac.Equal(mac.Sum(nil), sig) {
|
||||||
|
return nil, errHandoffInvalid
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HandoffStore) gcLocked() {
|
||||||
|
now := time.Now()
|
||||||
|
for jti, exp := range s.consumed {
|
||||||
|
if now.After(exp) {
|
||||||
|
delete(s.consumed, jti)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomID() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendHandoff(destURL, token string) (string, error) {
|
||||||
|
u, err := url.Parse(destURL)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return "", errHandoffInvalid
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("kc_handoff", token)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
274
src/internal/server/oidc/handoff_test.go
Normal file
274
src/internal/server/oidc/handoff_test.go
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
package oidc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keycape/internal/domain"
|
||||||
|
"keycape/internal/server/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool { return &v }
|
||||||
|
|
||||||
|
func isolationClients() map[string]*domain.Client {
|
||||||
|
return map[string]*domain.Client{
|
||||||
|
"coulomb-social": {
|
||||||
|
ClientID: "coulomb-social",
|
||||||
|
DisplayName: "coulomb.social",
|
||||||
|
RedirectURIs: []string{"https://coulomb.social/auth/callback/"},
|
||||||
|
AllowedScopes: []string{"openid", "profile"},
|
||||||
|
ClientType: "public",
|
||||||
|
MFARequired: boolPtr(false),
|
||||||
|
RegistrationURL: "https://users.example.com/register",
|
||||||
|
EnrollmentURL: "https://users.example.com/enroll",
|
||||||
|
},
|
||||||
|
"openbao-console": {
|
||||||
|
ClientID: "openbao-console",
|
||||||
|
DisplayName: "OpenBao",
|
||||||
|
RedirectURIs: []string{"https://bao.example.com/oidc/callback"},
|
||||||
|
AllowedScopes: []string{"openid", "profile"},
|
||||||
|
ClientType: "public",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isolationHandler(auth domain.AuthProvider, mfa domain.MFAProvider) *oidc.AuthorizeHandler {
|
||||||
|
return &oidc.AuthorizeHandler{
|
||||||
|
ClientConfig: isolationClients(),
|
||||||
|
Auth: auth,
|
||||||
|
MFA: mfa,
|
||||||
|
Sessions: oidc.NewSessionStore(),
|
||||||
|
Logins: oidc.NewLoginSessionStore(),
|
||||||
|
Handoffs: oidc.NewHandoffStore(),
|
||||||
|
Emitter: &captureEmitter{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoff_PromptCreate_EligibleClientRedirectsToAllowList(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {"coulomb-social"},
|
||||||
|
"redirect_uri": {"https://coulomb.social/auth/callback/"},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"scope": {"openid profile"},
|
||||||
|
"state": {"app-state"},
|
||||||
|
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"prompt": {"create"},
|
||||||
|
"tenant_hint": {"tenant:coulomb"},
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
loc, err := url.Parse(rec.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loc.Host != "users.example.com" || loc.Path != "/register" {
|
||||||
|
t.Fatalf("expected allow-listed registration URL, got %s", loc)
|
||||||
|
}
|
||||||
|
if loc.Query().Get("kc_handoff") == "" {
|
||||||
|
t.Fatal("expected kc_handoff on registration redirect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoff_PromptCreate_IneligibleClientRejected(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{}, &mockMFAProvider{})
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {"openbao-console"},
|
||||||
|
"redirect_uri": {"https://bao.example.com/oidc/callback"},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"scope": {"openid profile"},
|
||||||
|
"state": {"app-state"},
|
||||||
|
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"prompt": {"create"},
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoff_ReturnRestartsAuthorizeWithoutToken(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {"coulomb-social"},
|
||||||
|
"redirect_uri": {"https://coulomb.social/auth/callback/"},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"scope": {"openid profile"},
|
||||||
|
"state": {"app-state"},
|
||||||
|
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"prompt": {"create"},
|
||||||
|
}
|
||||||
|
start := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(start, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
|
||||||
|
token := mustQuery(t, start.Header().Get("Location"), "kc_handoff")
|
||||||
|
|
||||||
|
ret := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(ret, httptest.NewRequest(http.MethodGet, "/authorize/return?kc_handoff="+url.QueryEscape(token), nil))
|
||||||
|
if ret.Code != http.StatusFound {
|
||||||
|
t.Fatalf("return status=%d body=%s", ret.Code, ret.Body.String())
|
||||||
|
}
|
||||||
|
loc, err := url.Parse(ret.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loc.Path != "/authorize" {
|
||||||
|
t.Fatalf("return must restart /authorize, got %s", loc)
|
||||||
|
}
|
||||||
|
if loc.Query().Get("code") != "" {
|
||||||
|
t.Fatal("handoff return must not mint a token or code")
|
||||||
|
}
|
||||||
|
if loc.Query().Get("client_id") != "coulomb-social" {
|
||||||
|
t.Fatalf("client_id not preserved: %s", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoff_ReplayRejected(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {"coulomb-social"},
|
||||||
|
"redirect_uri": {"https://coulomb.social/auth/callback/"},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"scope": {"openid"},
|
||||||
|
"state": {"app-state"},
|
||||||
|
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"prompt": {"create"},
|
||||||
|
}
|
||||||
|
start := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(start, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
|
||||||
|
token := mustQuery(t, start.Header().Get("Location"), "kc_handoff")
|
||||||
|
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/authorize/return?kc_handoff="+url.QueryEscape(token), nil))
|
||||||
|
if first.Code != http.StatusFound {
|
||||||
|
t.Fatalf("first return status=%d", first.Code)
|
||||||
|
}
|
||||||
|
replay := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(replay, httptest.NewRequest(http.MethodGet, "/authorize/return?kc_handoff="+url.QueryEscape(token), nil))
|
||||||
|
if replay.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("replay status=%d body=%s", replay.Code, replay.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoff_TamperedEnvelopeRejected(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {"coulomb-social"},
|
||||||
|
"redirect_uri": {"https://coulomb.social/auth/callback/"},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"scope": {"openid"},
|
||||||
|
"state": {"app-state"},
|
||||||
|
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
"prompt": {"create"},
|
||||||
|
}
|
||||||
|
start := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(start, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
|
||||||
|
token := mustQuery(t, start.Header().Get("Location"), "kc_handoff")
|
||||||
|
tampered := token + "x"
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize/return?kc_handoff="+url.QueryEscape(tampered), nil))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("tampered status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoff_UnknownUserOffersSignupWithoutToken(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{callbackErr: domain.ErrAuthFailed}, &mockMFAProvider{})
|
||||||
|
h.PendingStates().Store("s-unknown", &oidc.PendingState{
|
||||||
|
ClientID: "coulomb-social",
|
||||||
|
RedirectURI: "https://coulomb.social/auth/callback/",
|
||||||
|
State: "s-unknown",
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=s-unknown", nil))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "/authorize/register?state=s-unknown") {
|
||||||
|
t.Fatalf("expected signup link, body=%s", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "code=") {
|
||||||
|
t.Fatal("unknown-user page must not mint a code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandoff_UnknownUserIneligibleHasNoSignupLink(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{callbackErr: domain.ErrAuthFailed}, &mockMFAProvider{})
|
||||||
|
h.PendingStates().Store("s-admin", &oidc.PendingState{
|
||||||
|
ClientID: "openbao-console",
|
||||||
|
RedirectURI: "https://bao.example.com/oidc/callback",
|
||||||
|
State: "s-admin",
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=s-admin", nil))
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "/authorize/register") {
|
||||||
|
t.Fatal("ineligible client must not receive a registration link")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeCallback_ExpiredStateRejected(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}}, &mockMFAProvider{})
|
||||||
|
h.PendingStates().Store("expired", &oidc.PendingState{
|
||||||
|
ClientID: "coulomb-social",
|
||||||
|
RedirectURI: "https://coulomb.social/auth/callback/",
|
||||||
|
State: "expired",
|
||||||
|
ExpiresAt: time.Now().Add(-time.Minute),
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=expired", nil))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthorizeCallback_ReplayAfterSuccessRejected(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}}, &mockMFAProvider{required: true})
|
||||||
|
h.PendingStates().Store("once", &oidc.PendingState{
|
||||||
|
ClientID: "coulomb-social",
|
||||||
|
RedirectURI: "https://coulomb.social/auth/callback/",
|
||||||
|
State: "once",
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(first, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=once", nil))
|
||||||
|
if first.Code != http.StatusFound {
|
||||||
|
t.Fatalf("first status=%d body=%s", first.Code, first.Body.String())
|
||||||
|
}
|
||||||
|
second := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(second, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=once", nil))
|
||||||
|
if second.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("replay status=%d body=%s", second.Code, second.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustQuery(t *testing.T, raw, key string) string {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
v := u.Query().Get(key)
|
||||||
|
if v == "" {
|
||||||
|
t.Fatalf("missing %s in %s", key, raw)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
130
src/internal/server/oidc/login_session.go
Normal file
130
src/internal/server/oidc/login_session.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keycape/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
loginCookieName = "kc_login"
|
||||||
|
loginSessionTTL = 8 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginSession is a KeyCape browser session that records the assurance
|
||||||
|
// already proven for a user. It is not client-specific: a later high-
|
||||||
|
// assurance client must still step up if the stored level is too low.
|
||||||
|
type LoginSession struct {
|
||||||
|
ID string
|
||||||
|
Username string
|
||||||
|
Level domain.AssuranceLevel
|
||||||
|
IssuedAt time.Time
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginSessionStore is an in-memory login-session map keyed by cookie value.
|
||||||
|
type LoginSessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*LoginSession
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLoginSessionStore returns an empty login-session store.
|
||||||
|
func NewLoginSessionStore() *LoginSessionStore {
|
||||||
|
return &LoginSessionStore{sessions: make(map[string]*LoginSession)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create stores a session and returns it.
|
||||||
|
func (s *LoginSessionStore) Create(username string, level domain.AssuranceLevel) *LoginSession {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id, err := randomID()
|
||||||
|
if err != nil {
|
||||||
|
panic("oidc: failed to generate login session id: " + err.Error())
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
sess := &LoginSession{
|
||||||
|
ID: id,
|
||||||
|
Username: username,
|
||||||
|
Level: level,
|
||||||
|
IssuedAt: now,
|
||||||
|
ExpiresAt: now.Add(loginSessionTTL),
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sessions[id] = sess
|
||||||
|
s.mu.Unlock()
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a live session by id.
|
||||||
|
func (s *LoginSessionStore) Get(id string) (*LoginSession, bool) {
|
||||||
|
if s == nil || id == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
sess, ok := s.sessions[id]
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
|
s.Delete(id)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return sess, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a session.
|
||||||
|
func (s *LoginSessionStore) Delete(id string) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.sessions, id)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LoginSessionStore) fromRequest(r *http.Request) *LoginSession {
|
||||||
|
if s == nil || r == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c, err := r.Cookie(loginCookieName)
|
||||||
|
if err != nil || c.Value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sess, ok := s.Get(c.Value)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return sess
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLoginCookie(w http.ResponseWriter, sess *LoginSession, secure bool) {
|
||||||
|
if sess == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: loginCookieName,
|
||||||
|
Value: sess.ID,
|
||||||
|
Path: "/",
|
||||||
|
Expires: sess.ExpiresAt,
|
||||||
|
MaxAge: int(time.Until(sess.ExpiresAt).Seconds()),
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: secure,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearLoginCookie(w http.ResponseWriter, secure bool) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: loginCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Secure: secure,
|
||||||
|
})
|
||||||
|
}
|
||||||
73
src/internal/server/oidc/logout.go
Normal file
73
src/internal/server/oidc/logout.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package oidc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"keycape/internal/domain"
|
||||||
|
profileerrors "keycape/internal/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogoutHandler implements GET /logout (OIDC RP-initiated logout subset).
|
||||||
|
// It clears the KeyCape login session and, when requested, redirects only to
|
||||||
|
// a statically registered client redirect URI.
|
||||||
|
type LogoutHandler struct {
|
||||||
|
ClientConfig map[string]*domain.Client
|
||||||
|
Logins *LoginSessionStore
|
||||||
|
SecureCookie bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP handles GET /logout.
|
||||||
|
func (h *LogoutHandler) ServeHTTP(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
|
||||||
|
}
|
||||||
|
|
||||||
|
if sess := h.Logins.fromRequest(r); sess != nil {
|
||||||
|
h.Logins.Delete(sess.ID)
|
||||||
|
}
|
||||||
|
clearLoginCookie(w, h.SecureCookie)
|
||||||
|
|
||||||
|
clientID := r.URL.Query().Get("client_id")
|
||||||
|
postLogout := r.URL.Query().Get("post_logout_redirect_uri")
|
||||||
|
if postLogout == "" {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("logged out"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client, ok := h.ClientConfig[clientID]
|
||||||
|
if !ok {
|
||||||
|
profileerrors.InvalidProfileUsage("unknown client_id", "client_id").
|
||||||
|
Write(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !uriRegistered(client.RedirectURIs, postLogout) {
|
||||||
|
profileerrors.RejectedForSafety(
|
||||||
|
"post_logout_redirect_uri is not a registered redirect URI",
|
||||||
|
"post_logout_redirect_uri",
|
||||||
|
).Write(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := url.Parse(postLogout)
|
||||||
|
if err != nil {
|
||||||
|
profileerrors.InvalidProfileUsage("invalid post_logout_redirect_uri", "post_logout_redirect_uri").
|
||||||
|
Write(w, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state := r.URL.Query().Get("state"); state != "" {
|
||||||
|
q := loc.Query()
|
||||||
|
q.Set("state", state)
|
||||||
|
loc.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, loc.String(), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func issuerIsHTTPS(issuer string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToLower(issuer), "https://")
|
||||||
|
}
|
||||||
222
src/internal/server/oidc/policy_isolation_test.go
Normal file
222
src/internal/server/oidc/policy_isolation_test.go
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
package oidc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"keycape/internal/domain"
|
||||||
|
"keycape/internal/server/oidc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPolicy_CoulombSocialPasswordOnlyWhenNoStrongerRule(t *testing.T) {
|
||||||
|
h := isolationHandler(
|
||||||
|
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
||||||
|
&mockMFAProvider{required: true, enrolled: true},
|
||||||
|
)
|
||||||
|
h.PendingStates().Store("social", &oidc.PendingState{
|
||||||
|
ClientID: "coulomb-social",
|
||||||
|
RedirectURI: "https://coulomb.social/auth/callback/",
|
||||||
|
PKCEChallenge: "abc",
|
||||||
|
PKCEChallengeMethod: "S256",
|
||||||
|
State: "social",
|
||||||
|
Scopes: []string{"openid"},
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=social", nil))
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(rec.Body.String(), "KeyCape MFA") {
|
||||||
|
t.Fatal("ordinary coulomb-social login must not render MFA")
|
||||||
|
}
|
||||||
|
loc, _ := url.Parse(rec.Header().Get("Location"))
|
||||||
|
if loc.Query().Get("code") == "" {
|
||||||
|
t.Fatal("expected authorization code")
|
||||||
|
}
|
||||||
|
sess, ok := h.Sessions.Get(loc.Query().Get("code"))
|
||||||
|
if !ok || sess.MFAVerified {
|
||||||
|
t.Fatalf("AAL1 login must record MFAVerified=false: %+v", sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicy_ProfileActionStepUpForcesMFA(t *testing.T) {
|
||||||
|
h := isolationHandler(
|
||||||
|
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
||||||
|
&mockMFAProvider{required: false, enrolled: true},
|
||||||
|
)
|
||||||
|
h.PendingStates().Store("step", &oidc.PendingState{
|
||||||
|
ClientID: "coulomb-social",
|
||||||
|
RedirectURI: "https://coulomb.social/auth/callback/",
|
||||||
|
State: "step",
|
||||||
|
ACRValues: []string{"aal2"},
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=step", nil))
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "KeyCape MFA") {
|
||||||
|
t.Fatalf("expected MFA challenge, status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicy_OpenBaoKeepsMandatoryMFA(t *testing.T) {
|
||||||
|
h := isolationHandler(
|
||||||
|
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
||||||
|
&mockMFAProvider{required: true, enrolled: true},
|
||||||
|
)
|
||||||
|
h.PendingStates().Store("bao", &oidc.PendingState{
|
||||||
|
ClientID: "openbao-console",
|
||||||
|
RedirectURI: "https://bao.example.com/oidc/callback",
|
||||||
|
State: "bao",
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=bao", nil))
|
||||||
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "KeyCape MFA") {
|
||||||
|
t.Fatalf("OpenBao must keep MFA, status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicy_LowAssuranceClientDoesNotSuppressHighAssurance(t *testing.T) {
|
||||||
|
h := isolationHandler(
|
||||||
|
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
||||||
|
&mockMFAProvider{required: true, enrolled: true},
|
||||||
|
)
|
||||||
|
h.PendingStates().Store("social", &oidc.PendingState{
|
||||||
|
ClientID: "coulomb-social",
|
||||||
|
RedirectURI: "https://coulomb.social/auth/callback/",
|
||||||
|
PKCEChallenge: "abc",
|
||||||
|
PKCEChallengeMethod: "S256",
|
||||||
|
State: "social",
|
||||||
|
Scopes: []string{"openid"},
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
first := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(first, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=social", nil))
|
||||||
|
if first.Code != http.StatusFound {
|
||||||
|
t.Fatalf("AAL1 status=%d body=%s", first.Code, first.Body.String())
|
||||||
|
}
|
||||||
|
cookie := first.Result().Cookies()
|
||||||
|
if len(cookie) == 0 {
|
||||||
|
t.Fatal("expected login session cookie after AAL1")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.PendingStates().Store("bao", &oidc.PendingState{
|
||||||
|
ClientID: "openbao-console",
|
||||||
|
RedirectURI: "https://bao.example.com/oidc/callback",
|
||||||
|
State: "bao",
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=y&state=bao", nil)
|
||||||
|
req.AddCookie(cookie[0])
|
||||||
|
second := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(second, req)
|
||||||
|
if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), "KeyCape MFA") {
|
||||||
|
t.Fatalf("AAL1 session must not satisfy OpenBao: status=%d body=%s", second.Code, second.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicy_NoFactorEnrollmentHandoffDoesNotBypass(t *testing.T) {
|
||||||
|
h := isolationHandler(
|
||||||
|
&mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
||||||
|
&mockMFAProvider{required: false, enrolled: false},
|
||||||
|
)
|
||||||
|
h.PendingStates().Store("enroll", &oidc.PendingState{
|
||||||
|
ClientID: "coulomb-social",
|
||||||
|
RedirectURI: "https://coulomb.social/auth/callback/",
|
||||||
|
State: "enroll",
|
||||||
|
ACRValues: []string{"aal2"},
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(rec, httptest.NewRequest(http.MethodGet, "/authorize/callback?code=x&state=enroll", nil))
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
loc, err := url.Parse(rec.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loc.Host != "users.example.com" || loc.Path != "/enroll" {
|
||||||
|
t.Fatalf("expected enrollment handoff, got %s", loc)
|
||||||
|
}
|
||||||
|
if loc.Query().Get("code") != "" {
|
||||||
|
t.Fatal("enrollment handoff must not mint a code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPolicy_ExactRedirectStillEnforced(t *testing.T) {
|
||||||
|
h := isolationHandler(&mockAuthProvider{authorizeURL: "https://authelia.example/auth"}, &mockMFAProvider{})
|
||||||
|
params := url.Values{
|
||||||
|
"client_id": {"coulomb-social"},
|
||||||
|
"redirect_uri": {"https://evil.example/callback"},
|
||||||
|
"response_type": {"code"},
|
||||||
|
"scope": {"openid"},
|
||||||
|
"state": {"s"},
|
||||||
|
"code_challenge": {"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"},
|
||||||
|
"code_challenge_method": {"S256"},
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogout_ClearsSessionSoHighAssuranceRequiresMFAAgain(t *testing.T) {
|
||||||
|
logins := oidc.NewLoginSessionStore()
|
||||||
|
h := &oidc.AuthorizeHandler{
|
||||||
|
ClientConfig: isolationClients(),
|
||||||
|
Auth: &mockAuthProvider{callbackResult: &domain.AuthResult{Username: "alice"}},
|
||||||
|
MFA: &mockMFAProvider{required: true, enrolled: true},
|
||||||
|
Sessions: oidc.NewSessionStore(),
|
||||||
|
Logins: logins,
|
||||||
|
Handoffs: oidc.NewHandoffStore(),
|
||||||
|
Emitter: &captureEmitter{},
|
||||||
|
}
|
||||||
|
aal2 := logins.Create("alice", domain.AssuranceAAL2)
|
||||||
|
logout := &oidc.LogoutHandler{ClientConfig: isolationClients(), Logins: logins}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/logout?client_id=coulomb-social&post_logout_redirect_uri="+
|
||||||
|
url.QueryEscape("https://coulomb.social/auth/callback/"), nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: "kc_login", Value: aal2.ID})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
logout.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("logout status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := logins.Get(aal2.ID); ok {
|
||||||
|
t.Fatal("logout must delete the login session")
|
||||||
|
}
|
||||||
|
|
||||||
|
h.PendingStates().Store("bao", &oidc.PendingState{
|
||||||
|
ClientID: "openbao-console",
|
||||||
|
RedirectURI: "https://bao.example.com/oidc/callback",
|
||||||
|
State: "bao",
|
||||||
|
ExpiresAt: time.Now().Add(time.Minute),
|
||||||
|
})
|
||||||
|
after := httptest.NewRequest(http.MethodGet, "/authorize/callback?code=z&state=bao", nil)
|
||||||
|
after.AddCookie(&http.Cookie{Name: "kc_login", Value: aal2.ID})
|
||||||
|
second := httptest.NewRecorder()
|
||||||
|
h.ServeHTTPCallback(second, after)
|
||||||
|
if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), "KeyCape MFA") {
|
||||||
|
t.Fatalf("after logout OpenBao must require MFA, status=%d body=%s", second.Code, second.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogout_RejectsUnregisteredPostLogoutRedirect(t *testing.T) {
|
||||||
|
logout := &oidc.LogoutHandler{
|
||||||
|
ClientConfig: isolationClients(),
|
||||||
|
Logins: oidc.NewLoginSessionStore(),
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/logout?client_id=coulomb-social&post_logout_redirect_uri="+
|
||||||
|
url.QueryEscape("https://evil.example/out"), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
logout.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -64,6 +64,10 @@ func (m *mockMFA) CheckMFARequired(_ context.Context, _ string) (bool, error) {
|
||||||
return m.required, m.checkErr
|
return m.required, m.checkErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockMFA) HasEnrolledFactor(_ context.Context, _ string) (bool, error) {
|
||||||
|
return m.required, m.checkErr
|
||||||
|
}
|
||||||
|
|
||||||
func (m *mockMFA) ValidateMFAToken(_ context.Context, _, _ string) error {
|
func (m *mockMFA) ValidateMFAToken(_ context.Context, _, _ string) error {
|
||||||
return m.mfaErr
|
return m.mfaErr
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ type: workplan
|
||||||
title: "Registration handoff and client-aware MFA policy"
|
title: "Registration handoff and client-aware MFA policy"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: key-cape
|
repo: key-cape
|
||||||
status: active
|
status: finished
|
||||||
owner: codex
|
owner: grok
|
||||||
topic_slug: netkingdom
|
topic_slug: netkingdom
|
||||||
created: "2026-08-09"
|
created: "2026-08-09"
|
||||||
updated: "2026-08-09"
|
updated: "2026-08-16"
|
||||||
depends_on:
|
depends_on:
|
||||||
- NK-WP-0025
|
- NK-WP-0025
|
||||||
state_hub_workstream_id: "70b78f21-be6d-4d6c-a537-037c38b2884a"
|
state_hub_workstream_id: "70b78f21-be6d-4d6c-a537-037c38b2884a"
|
||||||
|
|
@ -36,11 +36,17 @@ normal authorization flow and must not mint a token directly.
|
||||||
Done when unknown users can choose signup from an eligible authorization flow
|
Done when unknown users can choose signup from an eligible authorization flow
|
||||||
without open redirect, client substitution, or state replay.
|
without open redirect, client substitution, or state replay.
|
||||||
|
|
||||||
|
Implemented `prompt=create` and `/authorize/register` against the client's
|
||||||
|
static `registrationUrl`, plus HMAC-signed `kc_handoff` envelopes consumed
|
||||||
|
once at `/authorize/return`. Return restarts `/authorize` and never mints a
|
||||||
|
code. Ineligible clients get no signup link. Live registration entry remains
|
||||||
|
user-engine-owned per NK-WP-0025; KeyCape only issues the return envelope.
|
||||||
|
|
||||||
## T02 - Replace global MFA with client-aware minimum assurance
|
## T02 - Replace global MFA with client-aware minimum assurance
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KEY-WP-0008-T02
|
id: KEY-WP-0008-T02
|
||||||
status: progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c2b56182-e717-4ca3-84e3-0963b69ce32f"
|
state_hub_task_id: "c2b56182-e717-4ca3-84e3-0963b69ce32f"
|
||||||
```
|
```
|
||||||
|
|
@ -58,6 +64,13 @@ Implemented with nullable per-client `mfaRequired`: an explicit client value
|
||||||
overrides the provider default only for that client. Absent values preserve
|
overrides the provider default only for that client. Absent values preserve
|
||||||
the existing provider-driven policy.
|
the existing provider-driven policy.
|
||||||
|
|
||||||
|
2026-08-16: `DecideAssurance` now combines client minimum, requested ACR,
|
||||||
|
provider default, `max_age`, `prompt=login`, and current KeyCape login-session
|
||||||
|
level. An AAL1 session cannot satisfy an AAL2 client or `acr_values=aal2`.
|
||||||
|
`coulomb-social` in `config/dev-config.yaml` is `mfaRequired: false`; other
|
||||||
|
clients keep the provider default. Users without an enrolled factor are sent
|
||||||
|
to the client's `enrollmentUrl` instead of completing authorization.
|
||||||
|
|
||||||
## T03 - Support explicit step-up and fresh authentication
|
## T03 - Support explicit step-up and fresh authentication
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
@ -83,7 +96,7 @@ and `mfa: true` only after successful verification.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: KEY-WP-0008-T04
|
id: KEY-WP-0008-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d4208f77-f4a6-4f2e-a436-de4f779cfaca"
|
state_hub_task_id: "d4208f77-f4a6-4f2e-a436-de4f779cfaca"
|
||||||
```
|
```
|
||||||
|
|
@ -95,3 +108,11 @@ Keep static client registration and exact redirect rules unchanged.
|
||||||
|
|
||||||
Done when existing high-assurance clients pass unchanged and the new
|
Done when existing high-assurance clients pass unchanged and the new
|
||||||
coulomb-social journey passes live.
|
coulomb-social journey passes live.
|
||||||
|
|
||||||
|
2026-08-16: isolation tests cover known/unknown users, registration
|
||||||
|
eligibility, state expiry/replay, password-only coulomb-social, ACR step-up,
|
||||||
|
no-factor enrollment handoff, OpenBao mandatory MFA, cross-client AAL1
|
||||||
|
session reuse, logout, and exact redirect enforcement. Full Go suite passes.
|
||||||
|
Live coulomb-social AAL1/AAL2 isolation was already proven on railiance01
|
||||||
|
under NK-WP-0025-T05 (2026-08-14); this closeout adds the KeyCape-side
|
||||||
|
regression suite and `/logout`.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue