diff --git a/config/dev-config.yaml b/config/dev-config.yaml index e09675b..bf8ea91 100644 --- a/config/dev-config.yaml +++ b/config/dev-config.yaml @@ -31,6 +31,8 @@ clients: allowedScopes: ["openid", "profile", "email", "groups"] grantTypes: ["authorization_code"] clientType: "public" + # NK-WP-0051-T02: show uid/groups/assurance/client_id before returning. + signInDiagnosis: true - clientId: "demo-app" displayName: "Demo Application" redirectUris: diff --git a/docs/operations.md b/docs/operations.md index b920fa2..68e12db 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -263,3 +263,15 @@ https://github.com/authelia/authelia/blob/v4.38.19/web/src/views/LoginPortal/Sig Existing RP sessions and issued JWTs are not revoked. The original `/logout` endpoint retains its local-only contract. Actual shared-session destruction requires browser execution; a redirect-only smoke is not acceptance evidence. + +## Sign-in diagnosis page (NK-WP-0051-T02) + +A client with `signInDiagnosis: true` in its registration shows a page before +the redirect back to it: `uid`, `groups`, `assurance` and `client_id` as defined +in `net-kingdom/docs/sign-in-diagnosis.md`, with one continue link carrying the +unchanged code. Default is off. The repo enables it only for `openbao-admin` in +`config/dev-config.yaml`; the live flag is set by the config custody owner on the +OpenBao browser client in `config.yaml` of the `sso/keycape-config` Secret, and +takes effect on the next issuer restart. A redirect URI that is not http(s) +skips the page. Every `auth_success` log line now carries `client_id`, `uid` and +`assurance`, and no token, code, email or one-time password. diff --git a/spec/canonical-model.yaml b/spec/canonical-model.yaml index a657bcd..fca9b9d 100644 --- a/spec/canonical-model.yaml +++ b/spec/canonical-model.yaml @@ -200,6 +200,14 @@ entities: format: uri nullable: true description: "Factor-enrollment handoff target for unenrolled subjects." + signInDiagnosis: + type: boolean + description: > + Opt-in (default false) pre-redirect page showing uid, groups, + assurance and client_id for this authorization request, with one + continue action (net-kingdom/docs/sign-in-diagnosis.md, + NK-WP-0051-T02). KeyCape runtime presentation only; not exported + to Keycloak. tokenProfile: type: string runtime: false diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 483a613..16de34c 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -176,6 +176,7 @@ func main() { Handoffs: oidc.NewHandoffStore(), Issuer: issuer, Emitter: emitter, + Users: lldapAdapter, } if path := os.Getenv("KEYCAPE_POLICY_PATH"); path != "" { policies, err := policy.Open(path, clients) @@ -434,6 +435,7 @@ func buildClientRegistryWith(cfgClients []config.ClientConfig, getenv func(strin MFAOptional: c.MFAOptional, RegistrationURL: c.RegistrationURL, EnrollmentURL: c.EnrollmentURL, + SignInDiagnosis: c.SignInDiagnosis, } } return m, nil diff --git a/src/internal/config/config.go b/src/internal/config/config.go index 555f3dd..7895b73 100644 --- a/src/internal/config/config.go +++ b/src/internal/config/config.go @@ -65,6 +65,9 @@ type ClientConfig struct { MFARequired *bool `yaml:"mfaRequired,omitempty"` RegistrationURL string `yaml:"registrationUrl,omitempty"` EnrollmentURL string `yaml:"enrollmentUrl,omitempty"` + // SignInDiagnosis opts the client into the pre-redirect sign-in + // diagnosis page (NK-WP-0051-T02). Default off. + SignInDiagnosis bool `yaml:"signInDiagnosis,omitempty"` } // Load reads and parses the YAML config file at path. @@ -134,6 +137,7 @@ func (c *Config) Registrations() ([]domain.Client, error) { MFAOptional: cc.MFAOptional, RegistrationURL: cc.RegistrationURL, EnrollmentURL: cc.EnrollmentURL, + SignInDiagnosis: cc.SignInDiagnosis, }) } return clients, nil diff --git a/src/internal/domain/model.go b/src/internal/domain/model.go index 5a94da7..1cfda53 100644 --- a/src/internal/domain/model.go +++ b/src/internal/domain/model.go @@ -69,6 +69,10 @@ type Client struct { MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"` RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"` EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"` + // SignInDiagnosis opts this client into the pre-redirect sign-in + // diagnosis page (uid, groups, assurance, client_id) defined by + // net-kingdom/docs/sign-in-diagnosis.md (NK-WP-0051-T02). Default off. + SignInDiagnosis bool `yaml:"signInDiagnosis,omitempty" json:"signInDiagnosis,omitempty"` } // Membership links a user to a group. diff --git a/src/internal/server/oidc/authorize.go b/src/internal/server/oidc/authorize.go index 7f976c5..60447bc 100644 --- a/src/internal/server/oidc/authorize.go +++ b/src/internal/server/oidc/authorize.go @@ -79,6 +79,9 @@ type AuthorizeHandler struct { Handoffs *HandoffStore Issuer string Emitter telemetry.Emitter + // Users resolves directory groups for the sign-in diagnosis page. Only + // consulted for clients with SignInDiagnosis set; may be nil. + Users domain.UserRepository pending *pendingStateStore once sync.Once @@ -522,6 +525,8 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http. Endpoint: "/authorize/callback", Result: "success", Scopes: ps.Scopes, + UID: username, + Assurance: diagnosisAssurance(mfaVerified, freshMFA), }) // Redirect to client with code and state. @@ -534,6 +539,11 @@ func (h *AuthorizeHandler) completeAuthorization(w http.ResponseWriter, r *http. q.Set("code", authCode) q.Set("state", ps.State) redirectTo.RawQuery = q.Encode() + if client, ok := h.ClientConfig[ps.ClientID]; ok && client != nil && client.SignInDiagnosis { + if h.renderSignInDiagnosis(w, r, ps.ClientID, username, diagnosisAssurance(mfaVerified, freshMFA), redirectTo) { + return + } + } http.Redirect(w, r, redirectTo.String(), http.StatusFound) } diff --git a/src/internal/server/oidc/signin_diagnosis.go b/src/internal/server/oidc/signin_diagnosis.go new file mode 100644 index 0000000..ba82ee4 --- /dev/null +++ b/src/internal/server/oidc/signin_diagnosis.go @@ -0,0 +1,108 @@ +package oidc + +import ( + "html/template" + "net/http" + "net/url" + "sort" + "strings" +) + +// Sign-in diagnosis (NK-WP-0051-T02). +// +// The field names and the never-show list are defined only in +// net-kingdom/docs/sign-in-diagnosis.md. The page shows uid, groups, +// assurance and client_id for THIS authorization request. It never shows +// tokens, the authorization code (beyond the continue link target), email, +// one-time passwords, or raw memberOf DNs. + +// diagnosisAssurance is aal2 only when this request verified a one-time +// code. A reused session that was aal2 earlier is aal1 here: the contract's +// assurance is per request, so the session-satisfied MFAVerified from the +// assurance decision does not count unless the code was verified now. +func diagnosisAssurance(mfaVerified, freshMFA bool) string { + if mfaVerified && freshMFA { + return "aal2" + } + return "aal1" +} + +// renderSignInDiagnosis writes the pre-redirect page and reports true, or +// reports false (caller redirects as usual) when the redirect target is not +// an http(s) URL the template can safely place in a link. +func (h *AuthorizeHandler) renderSignInDiagnosis(w http.ResponseWriter, r *http.Request, clientID, uid, assurance string, continueTo *url.URL) bool { + if continueTo.Scheme != "http" && continueTo.Scheme != "https" { + return false + } + groups, groupsKnown := h.diagnosisGroups(r, uid) + + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Pragma", "no-cache") + // The continue URL carries the authorization code; keep it out of any + // Referer sent by this page. + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = signInDiagnosisTemplate.Execute(w, struct { + UID string + Groups []string + GroupsKnown bool + Assurance string + ClientID string + ContinueURL string + SignOutURL string + }{ + UID: uid, + Groups: groups, + GroupsKnown: groupsKnown, + Assurance: assurance, + ClientID: clientID, + ContinueURL: continueTo.String(), + SignOutURL: strings.TrimRight(h.Issuer, "/") + "/account/logout", + }) + return true +} + +// diagnosisGroups returns the uid's directory group names, sorted. The LLDAP +// adapter already reduces memberOf DNs to group names. +func (h *AuthorizeHandler) diagnosisGroups(r *http.Request, uid string) ([]string, bool) { + if h.Users == nil { + return nil, false + } + user, err := h.Users.LookupUser(r.Context(), uid) + if err != nil || user == nil { + return nil, false + } + groups := append([]string(nil), user.Groups...) + sort.Strings(groups) + return groups, true +} + +var signInDiagnosisTemplate = template.Must(template.New("signin-diagnosis").Parse(` + +
+ + + +Not this account? Sign out at {{.SignOutURL}} (or use a private browser window), then start the login again from the application.