diff --git a/README.md b/README.md index 1c10001..321e257 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ reimplementation of [navikt/mock-oauth2-server](https://github.com/navikt/mock-o materialize on first touch — no registration step. - A `/_mock` control plane to mint tokens directly, freeze or advance the clock, enqueue one-shot scenarios, and capture inbound requests for assertions. +- Login templates: config-defined named principals, selectable on the login page + or headlessly via `login_hint` — predictable identities for automated suites. - Drop-in compatibility with `mock-oauth2-server`: the same unprefixed environment variables and the same JSON configuration shape. - Zero-config and DB-less — it boots instantly and serves a `default` issuer. @@ -66,6 +68,7 @@ by what you need: - **Do** — task-focused how-to guides: [get tokens for every grant](https://meigma.github.io/mock-oidc/how-to/get-tokens-for-every-grant/), [drive the authorization-code flow](https://meigma.github.io/mock-oidc/how-to/drive-the-authorization-code-flow/), + [log in with login templates](https://meigma.github.io/mock-oidc/how-to/log-in-with-login-templates/), [shape token claims](https://meigma.github.io/mock-oidc/how-to/shape-token-claims/), [simulate expiry and time](https://meigma.github.io/mock-oidc/how-to/simulate-expiry-and-time/), [capture and assert requests](https://meigma.github.io/mock-oidc/how-to/capture-and-assert-requests/), diff --git a/docs/docs/explanation/parity.md b/docs/docs/explanation/parity.md index 5a10544..19e9d9b 100644 --- a/docs/docs/explanation/parity.md +++ b/docs/docs/explanation/parity.md @@ -50,6 +50,10 @@ Parity in intent also means being honest about intent *not* shared. Several upst **No arbitrary raw-response injection.** There is no facility to make the server return a hand-crafted, non-conforming raw HTTP response. mock-oidc's job is to behave like a *correct* identity provider, and every response it emits goes through the same token and error machinery so that what your client receives is internally consistent — a token that verifies, an error in the right envelope with the right status. An escape hatch for injecting arbitrary bytes would undermine that guarantee, and the legitimate need behind it — shaping what a specific token or callback contains — is already met by scenarios and minting. +## Beyond parity + +Parity was the v0.1.0 bar, not the ceiling. Where mock-oidc grows past upstream, it does so with keys upstream never defined — so an upstream config keeps loading unchanged and a mock-oidc config degrades gracefully (upstream ignores unknown keys too). The first such extension is [`loginTemplates`](../reference/configuration.md): named login principals that the login page offers as a pre-fill dropdown and that automated suites select headlessly via the standard `login_hint` parameter, in place of upstream's static custom-login-page file. See [Log in with login templates](../how-to/log-in-with-login-templates.md). + ## The shape of the trade Taken together, these choices describe a tool that is faithful to upstream's *reason for existing* and unsentimental about its *implementation history*. You get the behaviour a correct OAuth2/OIDC client expects, the defects quietly fixed, and a smaller, more honest feature set with the sharp edges labelled rather than hidden. For most suites the practical result is that swapping the servers changes almost nothing in your application code; where it does, the difference is a bug you no longer have to work around, or a gap you were told about in advance. diff --git a/docs/docs/how-to/drive-the-authorization-code-flow.md b/docs/docs/how-to/drive-the-authorization-code-flow.md index 2a28f1e..4a69d06 100644 --- a/docs/docs/how-to/drive-the-authorization-code-flow.md +++ b/docs/docs/how-to/drive-the-authorization-code-flow.md @@ -92,6 +92,10 @@ redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz&prompt=login" A missing `username` returns `400` with `error: invalid_request`. +To log in as a **predefined identity** — picked from a dropdown on this page or +resolved headlessly with `login_hint`, no form round-trip — see +[Log in with login templates](log-in-with-login-templates.md). + ## Add PKCE PKCE is optional and supports both `S256` and `plain`. Generate a verifier and diff --git a/docs/docs/how-to/log-in-with-login-templates.md b/docs/docs/how-to/log-in-with-login-templates.md new file mode 100644 index 0000000..8a780b4 --- /dev/null +++ b/docs/docs/how-to/log-in-with-login-templates.md @@ -0,0 +1,117 @@ +--- +title: Log in with login templates +description: Define named login principals in config, pick them from the login page, or complete a login headlessly with login_hint — no browser required. +--- + +# Log in with login templates + +Login templates are named principals — `{name, subject, claims}` — declared in +the JSON config. Once configured, the same template works two ways: a human +picks it from a dropdown on the interactive login page, and an automated test +names it with `login_hint` on the `authorize` request to complete the login +**headlessly** — no form, no browser, no pre-arrangement call. + +Templates are a mock-oidc extension (upstream `mock-oauth2-server` has no +equivalent). For the key's exact shape see the +[configuration reference](../reference/configuration.md); for the surrounding +flow see [Drive the authorization-code flow](drive-the-authorization-code-flow.md). + +## Define templates in the config + +Add a top-level `loginTemplates` array. Names must be unique and non-empty; +`subject` becomes the token `sub`; `claims` is an optional JSON object merged +into the minted tokens: + +```json +{ + "interactiveLogin": true, + "loginTemplates": [ + { + "name": "admin-alice", + "subject": "alice", + "claims": { "email": "alice@example.com", "roles": ["admin"] } + }, + { "name": "basic-bob", "subject": "bob" } + ] +} +``` + +For the standard container setup, mount the file and point `JSON_CONFIG_PATH` +at it: + +```sh +docker run --rm -p 8080:8080 \ + -e JSON_CONFIG_PATH=/config.json \ + -v "$(pwd)/config.json:/config.json:ro" \ + ghcr.io/meigma/mock-oidc +``` + +A bad template — blank name or subject, or a duplicate name — fails startup +with an error naming the offending entry, so a typo never ships silently. + +## Pick a template on the login page + +When templates are configured, the interactive login page grows a **Template** +dropdown listing them by name. Selecting one pre-fills the username and claims +fields; both stay **editable**, so you can tweak the identity before signing +in. The form submit is unchanged — the dropdown is purely a pre-fill. + +With no templates configured, the page renders exactly as before. + +## Log in headlessly with login_hint + +Name a template in the standard `login_hint` parameter on `GET /authorize` and +the server resolves it immediately — the code comes back in the `302` without a +login page, **even when `interactiveLogin: true` or `prompt=login` would +otherwise force one**: + +```sh +curl -i "http://localhost:8080/default/authorize?\ +response_type=code&client_id=test-client&\ +redirect_uri=http://localhost:3000/callback&scope=openid&state=xyz&\ +login_hint=admin-alice" +# => HTTP/1.1 302 Found +# => Location: http://localhost:3000/callback?code=&state=xyz +``` + +Exchange the code as usual; the tokens carry the template identity: + +```sh +curl -s http://localhost:8080/default/token \ + -d grant_type=authorization_code \ + -d code="$code" \ + -d client_id=test-client +# => id_token payload: {"sub":"alice","email":"alice@example.com","roles":["admin"],...} +``` + +A `login_hint` that names no configured template fails **loudly** as +`invalid_request` rather than falling through to a login page or a default +identity — an automated suite sees the typo immediately: + +```sh +curl -si "http://localhost:8080/default/authorize?\ +response_type=code&client_id=test-client&\ +redirect_uri=http://localhost:3000/callback&login_hint=nobody" \ + | grep -i '^location:' +# => location: http://localhost:3000/callback?error=invalid_request&error_description=... +``` + +## Use it from an automated test + +`login_hint` is a standard OIDC parameter, so any off-the-shelf client library +can send it without custom HTTP code — configure the library's authorize +request with `login_hint=` and run the flow normally. Because +the template is selected *by the client at flow time*, parallel tests never +contend over shared server state; contrast the one-shot +[scenario queue](shape-token-claims.md), which pre-arranges the *next* token +server-side. + +!!! note "Semantics to know" + - **Templates are global**, not per-issuer: the same names resolve on every + issuer. + - **The hint only acts while templates are configured.** With an empty or + absent `loginTemplates`, `login_hint` is ignored entirely. + - **Template claims merge like login claims**: they are added only where a + token callback or registered claim (like `sub`) has not already set the + value. See [Shape token claims](shape-token-claims.md) for the resolution + order. diff --git a/docs/docs/openapi.yaml b/docs/docs/openapi.yaml index 6a0c602..14d08a9 100644 --- a/docs/docs/openapi.yaml +++ b/docs/docs/openapi.yaml @@ -920,6 +920,11 @@ paths: name: code_challenge_method schema: type: string + - explode: false + in: query + name: login_hint + schema: + type: string responses: "302": content: @@ -1002,6 +1007,11 @@ paths: name: code_challenge_method schema: type: string + - explode: false + in: query + name: login_hint + schema: + type: string requestBody: content: application/x-www-form-urlencoded: diff --git a/docs/docs/reference/configuration.md b/docs/docs/reference/configuration.md index 9a62f13..8469992 100644 --- a/docs/docs/reference/configuration.md +++ b/docs/docs/reference/configuration.md @@ -137,6 +137,13 @@ The document is compatible with the upstream `mock-oauth2-server` config format. ] } ], + "loginTemplates": [ + { + "name": "admin-alice", + "subject": "alice", + "claims": { "email": "alice@example.com", "roles": ["admin"] } + } + ], "httpServer": { "ssl": {} } } ``` @@ -180,6 +187,20 @@ The document is compatible with the upstream `mock-oauth2-server` config format. [control-plane reference](control-plane.md) and [Shape token claims](../how-to/shape-token-claims.md). +`loginTemplates` (array) +: Named login principals (a **mock-oidc extension** — upstream has no + equivalent key; the whole list is global, not per-issuer). Each entry has a + unique non-empty `name`, a non-empty `subject`, and an optional `claims` + object. Templates surface in two places: the interactive login page offers + them as a pre-fill dropdown, and `login_hint=` on `GET /authorize` + resolves the template **headlessly** — the code is issued immediately, even + when `interactiveLogin` or `prompt=login` would otherwise force the page. + While templates are configured, a `login_hint` naming no template is a hard + `invalid_request`; with the list empty or absent, `login_hint` is ignored. + Template claims merge like login claims (added only where a callback or + registered claim has not already set the value). See + [Log in with login templates](../how-to/log-in-with-login-templates.md). + `httpServer` (string or object) : A string form is accepted for upstream compatibility. The object form `{ "ssl": {} }` enables an in-process self-signed `localhost` certificate on diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index ad14001..f9ef238 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -40,6 +40,7 @@ nav: - How-to guides: - Get tokens for every grant: how-to/get-tokens-for-every-grant.md - Drive the authorization-code flow: how-to/drive-the-authorization-code-flow.md + - Log in with login templates: how-to/log-in-with-login-templates.md - Shape token claims: how-to/shape-token-claims.md - Simulate expiry and time: how-to/simulate-expiry-and-time.md - Capture and assert requests: how-to/capture-and-assert-requests.md diff --git a/internal/app/app.go b/internal/app/app.go index 3b0c74b..08702b0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -338,16 +338,18 @@ func buildWiring(o options, logger *slog.Logger, selfAddr string) (wiring, error oidc.WithRefreshRotation(o.seed.RotateRefreshToken), oidc.WithCallbackQueue(queue), ) - authorize := oidc.NewAuthorizeService(codes, clock, o.seed.InteractiveLogin) + authorize := oidc.NewAuthorizeService(codes, clock, o.seed.InteractiveLogin, + oidc.WithLoginTemplates(o.seed.LoginTemplates)) session := oidc.NewSessionService(sign, refresh, clock) registerOIDC := httpapi.Registrar(httpapi.Deps{ - Provider: provider, - Tokens: tokens, - Authorize: authorize, - Session: session, - Logger: logger, - SelfAddr: selfAddr, + Provider: provider, + Tokens: tokens, + Authorize: authorize, + Session: session, + Logger: logger, + SelfAddr: selfAddr, + LoginTemplates: o.seed.LoginTemplates, }) // The mutable memory.Clock satisfies controlapi.ClockController. A test-injected diff --git a/internal/config/jsonconfig.go b/internal/config/jsonconfig.go index 66851c2..6efddf9 100644 --- a/internal/config/jsonconfig.go +++ b/internal/config/jsonconfig.go @@ -78,6 +78,12 @@ type Seed struct { // tokens without a runtime scenario. Empty → every issuer is zero-config // (materialized on demand with the built-in default callback). IssuerRecords []oidc.IssuerRecord + + // LoginTemplates carries the config-declared named principals (a mock-oidc + // extension). The composition root hands them to the AuthorizeService (headless + // login_hint resolution) and the httpapi login page (pre-fill dropdown). Empty + // → login_hint is ignored and no dropdown renders. + LoginTemplates oidc.LoginTemplates } // DefaultSeed is the zero-config seed used when no JSON config is present. @@ -99,6 +105,10 @@ type document struct { // enqueue-scenario DTO accepts, so a callback described as JSON parses // identically whether it arrives at startup (here) or at runtime (/_mock). TokenCallbacks []callbackDoc `json:"tokenCallbacks"` + // LoginTemplates is the declarative named-principal list (a mock-oidc + // extension; upstream has no equivalent key). Each entry is selectable on the + // login page and resolvable headlessly via login_hint. + LoginTemplates []loginTemplateDoc `json:"loginTemplates"` // HTTPServer mirrors upstream's httpServer field (a bare string or an object // carrying an optional `ssl` block). Its presence with an ssl object turns // HTTPS on; the concrete server type is otherwise ignored. @@ -161,6 +171,14 @@ type requestMappingDoc struct { Claims map[string]any `json:"claims"` } +// loginTemplateDoc is one declarative login template: a named principal +// ({name, subject, claims}) selectable on the login page or via login_hint. +type loginTemplateDoc struct { + Name string `json:"name"` + Subject string `json:"subject"` + Claims map[string]any `json:"claims"` +} + type tokenProviderDoc struct { SystemTime string `json:"systemTime"` // RFC3339; "" → live clock KeyProvider keyProviderDoc `json:"keyProvider"` @@ -256,9 +274,39 @@ func (d document) toSeed() (Seed, error) { } seed.IssuerRecords = records + templates, err := toLoginTemplates(d.LoginTemplates) + if err != nil { + return Seed{}, err + } + seed.LoginTemplates = templates + return seed, nil } +// toLoginTemplates maps the declarative template entries onto the domain +// constructors (oidc.NewLoginTemplate per entry, oidc.NewLoginTemplates for the +// unique-name invariant), so a bad template — blank name/subject, malformed +// claims, duplicate name — fails startup with an index-tagged error. +func toLoginTemplates(docs []loginTemplateDoc) (oidc.LoginTemplates, error) { + if len(docs) == 0 { + return oidc.LoginTemplates{}, nil + } + + templates := make([]oidc.LoginTemplate, 0, len(docs)) + for i, d := range docs { + claims, err := oidc.NewClaimSet(d.Claims) + if err != nil { + return oidc.LoginTemplates{}, fmt.Errorf("loginTemplates[%d]: %w", i, err) + } + t, err := oidc.NewLoginTemplate(d.Name, oidc.Subject(d.Subject), claims.Custom) + if err != nil { + return oidc.LoginTemplates{}, fmt.Errorf("loginTemplates[%d]: %w", i, err) + } + templates = append(templates, t) + } + return oidc.NewLoginTemplates(templates...) +} + // toIssuerRecords groups the declarative callbacks by issuer into IssuerRecords, // preserving each issuer's declared (first-match) order. Grouping lets the token // service walk one issuer's configured callbacks in the order the operator wrote diff --git a/internal/config/jsonconfig_test.go b/internal/config/jsonconfig_test.go index bed1bcd..38a9d39 100644 --- a/internal/config/jsonconfig_test.go +++ b/internal/config/jsonconfig_test.go @@ -241,6 +241,94 @@ func TestLoadSeedTokenCallbacksRejectsReservedIssuer(t *testing.T) { assert.ErrorIs(t, err, oidc.ErrReservedIssuer) } +// TestLoadSeedLoginTemplates verifies the loginTemplates parser maps entries +// through the domain constructors, preserving declaration order and claims. +func TestLoadSeedLoginTemplates(t *testing.T) { + t.Parallel() + + vp := viper.New() + vp.Set("json-config", `{ + "loginTemplates": [ + {"name": "admin-alice", "subject": "alice", "claims": {"email": "alice@example.com"}}, + {"name": "basic-bob", "subject": "bob"} + ] + }`) + + seed, err := config.LoadSeed(vp) + require.NoError(t, err) + require.Equal(t, 2, seed.LoginTemplates.Len()) + + all := seed.LoginTemplates.All() + assert.Equal(t, oidc.LoginTemplateName("admin-alice"), all[0].Name, "declaration order preserved") + assert.Equal(t, oidc.Subject("alice"), all[0].Subject) + email, ok := all[0].Claims.Get("email") + require.True(t, ok) + assert.Equal(t, "alice@example.com", email) + assert.Equal(t, oidc.LoginTemplateName("basic-bob"), all[1].Name) + assert.Equal(t, 0, all[1].Claims.Len(), "claimless template parses to an empty set") +} + +// TestLoadSeedLoginTemplatesRejectsInvalid verifies a bad template — blank name, +// blank subject, or a duplicate name — fails LoadSeed (fail-fast startup). +func TestLoadSeedLoginTemplatesRejectsInvalid(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + json string + wantErr error + wantIdx string + }{ + { + "blank name", + `{"loginTemplates": [{"name": "", "subject": "alice"}]}`, + oidc.ErrBlankTemplateName, + "loginTemplates[0]", + }, + { + "blank subject", + `{"loginTemplates": [{"name": "a", "subject": "x"}, {"name": "b", "subject": " "}]}`, + oidc.ErrBlankTemplateSubject, + "loginTemplates[1]", + }, + { + "duplicate name", + `{"loginTemplates": [{"name": "dup", "subject": "alice"}, {"name": "dup", "subject": "bob"}]}`, + oidc.ErrDuplicateTemplateName, + "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + vp := viper.New() + vp.Set("json-config", tc.json) + + _, err := config.LoadSeed(vp) + require.Error(t, err) + require.ErrorIs(t, err, tc.wantErr) + if tc.wantIdx != "" { + assert.Contains(t, err.Error(), tc.wantIdx, "index-tagged error") + } + }) + } +} + +// TestLoadSeedNoLoginTemplates verifies an absent loginTemplates leaves the seed +// empty (login_hint ignored, no dropdown). +func TestLoadSeedNoLoginTemplates(t *testing.T) { + t.Parallel() + + vp := viper.New() + vp.Set("json-config", `{"interactiveLogin": true}`) + + seed, err := config.LoadSeed(vp) + require.NoError(t, err) + assert.Equal(t, 0, seed.LoginTemplates.Len()) +} + // TestLoadSeedNoTokenCallbacks verifies an absent tokenCallbacks leaves the seed's // IssuerRecords empty (every issuer is zero-config, materialized on demand). func TestLoadSeedNoTokenCallbacks(t *testing.T) { diff --git a/internal/integration/logintemplate_flow_test.go b/internal/integration/logintemplate_flow_test.go new file mode 100644 index 0000000..a8f6a2e --- /dev/null +++ b/internal/integration/logintemplate_flow_test.go @@ -0,0 +1,184 @@ +//go:build integration + +package integration + +import ( + "context" + "encoding/json" + "io" + "maps" + "net/http" + "net/url" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// loginTemplatesConfig is the JSON config the login-template container tests +// boot with: interactive login FORCED on, so the headless login_hint path is +// proven to bypass the login page, not merely ride the non-interactive default. +const loginTemplatesConfig = `{ + "interactiveLogin": true, + "loginTemplates": [ + {"name": "admin-alice", "subject": "alice", "claims": {"email": "alice@example.com", "roles": ["admin"]}}, + {"name": "basic-bob", "subject": "bob"} + ] +}` + +// TestContainerLoginTemplates drives the login-template acceptance path against +// the shipped image: the login page offers the configured templates as a +// dropdown, a known login_hint completes the authorization-code flow headlessly +// (no browser, interactive login forced on) with the template's subject and +// claims in the minted id_token, and an unknown hint fails loudly as +// invalid_request. +func TestContainerLoginTemplates(t *testing.T) { + ctx := context.Background() + + skipIfImageMissing(ctx, t) + + req := testcontainers.ContainerRequest{ + Image: imageTag, + ExposedPorts: []string{apiPort}, + AlwaysPullImage: false, + Env: map[string]string{"JSON_CONFIG": loginTemplatesConfig}, + WaitingFor: wait.ForHTTP("/isalive"). + WithPort(apiPort). + WithStartupTimeout(60 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + require.NoError(t, err, "boot %s with loginTemplates config", imageTag) + t.Cleanup(func() { + if terr := ctr.Terminate(context.Background()); terr != nil { + t.Logf("terminate container: %v", terr) + } + }) + + host, err := ctr.Host(ctx) + require.NoError(t, err) + mapped, err := ctr.MappedPort(ctx, "8080") + require.NoError(t, err) + base := "http://" + host + ":" + mapped.Port() + + var disco struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + } + getJSON(ctx, t, base+"/default/.well-known/openid-configuration", &disco) + keys := fetchJWKSKeys(ctx, t, disco.JWKSURI) + require.NotEmpty(t, keys) + + const clientID = "test-suite" + authQuery := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {"http://127.0.0.1/cb"}, + "state": {"tpl-1"}, + } + + // 1. Without a hint, interactive login renders the page WITH the dropdown. + pageStatus, pageBody := getRaw(ctx, t, disco.AuthorizationEndpoint+"?"+authQuery.Encode()) + require.Equal(t, http.StatusOK, pageStatus, "interactive login page renders") + page := string(pageBody) + assert.Contains(t, page, `id="tmpl-select"`, "template dropdown offered") + assert.Contains(t, page, ">admin-alice", "template listed by name") + assert.Contains(t, page, ">basic-bob") + + // 2. A known login_hint bypasses the page and issues the code headlessly. + hinted := url.Values{} + maps.Copy(hinted, authQuery) + hinted.Set("login_hint", "admin-alice") + code, state := authorizeForCode(ctx, t, disco.AuthorizationEndpoint+"?"+hinted.Encode()) + require.NotEmpty(t, code, "code issued headlessly despite interactiveLogin:true") + assert.Equal(t, "tpl-1", state) + + // 3. Redeem the code and verify the template identity in the id_token. + var tok struct { + IDToken string `json:"id_token"` + } + status, body := postTokenForm(ctx, t, disco.TokenEndpoint, url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {clientID}, + }) + require.Equalf(t, http.StatusOK, status, "token exchange: %s", body) + require.NoError(t, json.Unmarshal(body, &tok)) + require.NotEmpty(t, tok.IDToken) + + idClaims := jwt.MapClaims{} + parsed, err := jwt.ParseWithClaims(tok.IDToken, idClaims, + func(t *jwt.Token) (any, error) { + kid, _ := t.Header["kid"].(string) + key, ok := keys[kid] + if !ok { + return nil, jwt.ErrTokenUnverifiable + } + return key, nil + }, + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithIssuer(disco.Issuer), + jwt.WithAudience(clientID), + ) + require.NoError(t, err) + require.True(t, parsed.Valid) + assert.Equal(t, "alice", idClaims["sub"], "template subject on the id_token") + assert.Equal(t, "alice@example.com", idClaims["email"], "template claim on the id_token") + assert.Equal(t, []any{"admin"}, idClaims["roles"], "structured template claim preserved") + + // 4. An unknown hint fails loudly: error redirected into redirect_uri. + unknown := url.Values{} + maps.Copy(unknown, authQuery) + unknown.Set("login_hint", "nobody") + errStatus, errLoc := authorizeNoRedirect(ctx, t, disco.AuthorizationEndpoint+"?"+unknown.Encode()) + require.Equal(t, http.StatusFound, errStatus) + loc, err := url.Parse(errLoc) + require.NoError(t, err) + assert.Equal(t, "invalid_request", loc.Query().Get("error"), "unknown template is a hard error") + assert.Contains(t, loc.Query().Get("error_description"), "nobody") + assert.Empty(t, loc.Query().Get("code")) +} + +// getRaw GETs a URL (following no redirects) and returns the status + body — +// for asserting on rendered HTML. +func getRaw(ctx context.Context, t *testing.T, rawURL string) (int, []byte) { + t.Helper() + + client := &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + return resp.StatusCode, body +} + +// authorizeNoRedirect GETs the authorize URL without following the 302 and +// returns the status + raw Location header, so error redirects are inspectable. +func authorizeNoRedirect(ctx context.Context, t *testing.T, authURL string) (int, string) { + t.Helper() + + client := &http.Client{ + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + return resp.StatusCode, resp.Header.Get("Location") +} diff --git a/internal/oidc/authorize.go b/internal/oidc/authorize.go index edf8586..6e6713d 100644 --- a/internal/oidc/authorize.go +++ b/internal/oidc/authorize.go @@ -17,6 +17,7 @@ type AuthorizeService struct { codes CodeStore clock Clock interactiveLogin bool + templates LoginTemplates newCode func() string } @@ -33,6 +34,15 @@ func WithAuthorizeCodeID(newCode func() string) AuthorizeOption { } } +// WithLoginTemplates installs the config-declared login templates. A non-empty +// collection arms the login_hint branch of Authorize; the default (empty) +// leaves login_hint ignored. +func WithLoginTemplates(templates LoginTemplates) AuthorizeOption { + return func(s *AuthorizeService) { + s.templates = templates + } +} + // NewAuthorizeService wires the /authorize use cases over the CodeStore and // Clock. interactiveLogin is the server-config flag that forces the login page // even when the request does not ask for it. The code ID source defaults to a @@ -60,15 +70,26 @@ func NewAuthorizeService( return s } -// Authorize decides between issuing a code and requiring interactive login: the -// server interactiveLogin flag OR prompt ∈ {login,consent,select_account} forces -// the login page; prompt=none never does. Only response_type=code is -// dispatched — the hybrid/implicit members are advertised in discovery but -// rejected here as invalid_grant. +// Authorize decides between issuing a code and requiring interactive login. +// When login templates are configured and the request carries a login_hint, the +// hint wins outright: a matching template resolves headlessly to a code (even +// over interactiveLogin and prompt=login), and an unknown name is a hard +// invalid_request — never a silent fallthrough. Otherwise the server +// interactiveLogin flag OR prompt ∈ {login,consent,select_account} forces the +// login page; prompt=none never does. Only response_type=code is dispatched — +// the hybrid/implicit members are advertised in discovery but rejected here as +// invalid_grant. func (s *AuthorizeService) Authorize(ctx context.Context, req AuthorizeRequest) (AuthorizeResult, error) { if req.ResponseType != ResponseTypeCode { return AuthorizeResult{}, InvalidGrant("response_type " + string(req.ResponseType) + " not supported.") } + if s.templates.Len() > 0 && req.LoginHint != "" { + tmpl, ok := s.templates.Lookup(LoginTemplateName(req.LoginHint)) + if !ok { + return AuthorizeResult{}, UnknownLoginTemplate(req.LoginHint) + } + return s.issueCode(ctx, req, tmpl.Submission()) // template wins: headless login + } if s.interactiveLogin || req.Prompt.RequiresLogin() { return AuthorizeResult{Kind: AuthorizeShowLogin, Request: req}, nil } diff --git a/internal/oidc/authorize_test.go b/internal/oidc/authorize_test.go index bb7bf6a..3fdc8bb 100644 --- a/internal/oidc/authorize_test.go +++ b/internal/oidc/authorize_test.go @@ -79,6 +79,129 @@ func TestAuthorizeTriggerMatrix(t *testing.T) { } } +// authorizeTestTemplates builds the two-template collection the login_hint +// cases share: admin-alice carries claims, basic-bob has none. +func authorizeTestTemplates(t *testing.T) oidc.LoginTemplates { + t.Helper() + + var claims oidc.CustomClaims + claims.Set("email", "alice@example.com") + admin, err := oidc.NewLoginTemplate("admin-alice", "alice", claims) + require.NoError(t, err) + basic, err := oidc.NewLoginTemplate("basic-bob", "bob", oidc.CustomClaims{}) + require.NoError(t, err) + + templates, err := oidc.NewLoginTemplates(admin, basic) + require.NoError(t, err) + return templates +} + +// TestAuthorizeLoginHintMatrix covers the login_hint decision layered over the +// login-vs-code matrix: a known template wins over interactiveLogin AND +// prompt=login (headless code), an unknown name is a hard invalid_request while +// templates are configured, and the hint is ignored entirely when no templates +// exist or when it is empty. +func TestAuthorizeLoginHintMatrix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + templates bool + hint string + interactiveLogin bool + prompt oidc.Prompt + wantKind oidc.AuthorizeResultKind + wantErr bool + }{ + {"known hint issues code non-interactive", true, "admin-alice", false, "", oidc.AuthorizeRedirect, false}, + {"known hint beats interactiveLogin", true, "admin-alice", true, "", oidc.AuthorizeRedirect, false}, + {"known hint beats prompt=login", true, "admin-alice", true, oidc.PromptLogin, oidc.AuthorizeRedirect, false}, + {"unknown hint is invalid_request", true, "nobody", false, "", 0, true}, + { + "hint ignored without templates (interactive)", + false, + "admin-alice", + true, + "", + oidc.AuthorizeShowLogin, + false, + }, + { + "hint ignored without templates (direct code)", + false, + "admin-alice", + false, + "", + oidc.AuthorizeRedirect, + false, + }, + {"empty hint with templates shows login", true, "", true, "", oidc.AuthorizeShowLogin, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + codes := mocks.NewCodeStore(t) + if !tc.wantErr && tc.wantKind != oidc.AuthorizeShowLogin { + codes.EXPECT().Save(mock.Anything, oidc.AuthorizationCode("code-h"), mock.Anything).Return(nil) + } + clock := oidc.NewFixedClock(oidc.NewInstant(time.Unix(1_700_000_000, 0))) + opts := []oidc.AuthorizeOption{oidc.WithAuthorizeCodeID(func() string { return "code-h" })} + if tc.templates { + opts = append(opts, oidc.WithLoginTemplates(authorizeTestTemplates(t))) + } + svc := oidc.NewAuthorizeService(codes, clock, tc.interactiveLogin, opts...) + + req := baseAuthorizeRequest() + req.Prompt = tc.prompt + req.LoginHint = tc.hint + res, err := svc.Authorize(context.Background(), req) + + if tc.wantErr { + var perr *oidc.ProtocolError + require.ErrorAs(t, err, &perr) + assert.Equal(t, oidc.CodeInvalidRequest, perr.Code) + assert.Contains(t, perr.Description, "nobody") + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantKind, res.Kind) + }) + } +} + +// TestAuthorizeLoginHintCachesTemplateSubmission confirms a resolved template +// lands in the cached CodeRecord as an ordinary login snapshot (subject + +// claims), so mint-time putIfAbsent merging applies to template claims exactly +// as it does to interactively-typed ones. +func TestAuthorizeLoginHintCachesTemplateSubmission(t *testing.T) { + t.Parallel() + + var saved oidc.CodeRecord + codes := mocks.NewCodeStore(t) + codes.EXPECT(). + Save(mock.Anything, oidc.AuthorizationCode("code-t"), mock.Anything). + Run(func(_ context.Context, _ oidc.AuthorizationCode, rec oidc.CodeRecord) { saved = rec }). + Return(nil) + clock := oidc.NewFixedClock(oidc.NewInstant(time.Unix(1_700_000_000, 0))) + svc := oidc.NewAuthorizeService(codes, clock, true, + oidc.WithAuthorizeCodeID(func() string { return "code-t" }), + oidc.WithLoginTemplates(authorizeTestTemplates(t))) + + req := baseAuthorizeRequest() + req.LoginHint = "admin-alice" + res, err := svc.Authorize(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, oidc.AuthorizeRedirect, res.Kind) + + require.NotNil(t, saved.Login) + assert.Equal(t, oidc.Subject("alice"), saved.Login.Username) + email, ok := saved.Login.Claims.Get("email") + assert.True(t, ok) + assert.Equal(t, "alice@example.com", email) +} + // TestAuthorizeRejectsNonCodeResponseType asserts only response_type=code is // dispatched; the advertised-but-unimplemented members are invalid_grant. func TestAuthorizeRejectsNonCodeResponseType(t *testing.T) { diff --git a/internal/oidc/httpapi/authorize.go b/internal/oidc/httpapi/authorize.go index b4dcce4..eb65a0d 100644 --- a/internal/oidc/httpapi/authorize.go +++ b/internal/oidc/httpapi/authorize.go @@ -27,6 +27,7 @@ type authorizeParams struct { Prompt string CodeChallenge string CodeChallengeMethod string + LoginHint string } // AuthorizeInput is the GET /{issuer}/authorize input: the issuer path segment @@ -45,6 +46,7 @@ type AuthorizeInput struct { Prompt string `query:"prompt"` CodeChallenge string `query:"code_challenge"` CodeChallengeMethod string `query:"code_challenge_method"` + LoginHint string `query:"login_hint"` } func (i *AuthorizeInput) issuerID() string { return i.Issuer } @@ -62,6 +64,7 @@ func (i *AuthorizeInput) params() authorizeParams { Prompt: i.Prompt, CodeChallenge: i.CodeChallenge, CodeChallengeMethod: i.CodeChallengeMethod, + LoginHint: i.LoginHint, } } @@ -123,13 +126,34 @@ func (h *handlers) renderAuthorizeResult(result oidc.AuthorizeResult) *BrowserOu // loginPage renders the interactive login form, whose action is the same // /authorize URL with the authorize query string preserved (so the POST carries // the parameters back). The URL is auto-escaped in the template's attribute -// context. +// context. Configured login templates render as a pre-fill dropdown; the domain +// never sees the dropdown — a selection only populates the form fields. func (h *handlers) loginPage(issuer string, p authorizeParams) *BrowserOutput { return htmlOutput(http.StatusOK, tmplLogin, loginData{ - Action: authorizeActionURL(issuer, p), + Action: authorizeActionURL(issuer, p), + Templates: h.loginTemplateViews(), }) } +// loginTemplateViews projects the configured login templates into the login.html +// dropdown model, serializing each claim set to JSON at the edge (the core stays +// JSON-free). Nil when no templates are configured, which suppresses the dropdown. +func (h *handlers) loginTemplateViews() []loginTemplateView { + all := h.deps.LoginTemplates.All() + if len(all) == 0 { + return nil + } + views := make([]loginTemplateView, 0, len(all)) + for _, t := range all { + views = append(views, loginTemplateView{ + Name: string(t.Name), + Username: string(t.Subject), + ClaimsJSON: claimsToJSON(t.Claims), + }) + } + return views +} + // authorizeError renders a protocol error on the /authorize surface: it appends // the OAuth2 error into redirect_uri (per response_mode) when a usable // redirect_uri is present, else it renders the direct HTML error page at the @@ -162,6 +186,7 @@ func decodeAuthorizeRequest(issuer oidc.IssuerID, p authorizeParams) oidc.Author State: p.State, Prompt: oidc.Prompt(p.Prompt), ResponseMode: oidc.ResponseMode(p.ResponseMode), + LoginHint: p.LoginHint, } if p.Nonce != "" { n := oidc.Nonce(p.Nonce) @@ -192,6 +217,7 @@ func authorizeActionURL(issuer string, p authorizeParams) string { setNonEmpty(q, "prompt", p.Prompt) setNonEmpty(q, "code_challenge", p.CodeChallenge) setNonEmpty(q, "code_challenge_method", p.CodeChallengeMethod) + setNonEmpty(q, "login_hint", p.LoginHint) path := "/" + url.PathEscape(issuer) + "/authorize" if enc := q.Encode(); enc != "" { diff --git a/internal/oidc/httpapi/authorize_test.go b/internal/oidc/httpapi/authorize_test.go index 6b939a3..830ac3a 100644 --- a/internal/oidc/httpapi/authorize_test.go +++ b/internal/oidc/httpapi/authorize_test.go @@ -14,6 +14,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/meigma/mock-oidc/internal/oidc" ) // postNoRedirect POSTs a url-encoded form without following the 302, so the test @@ -346,3 +348,147 @@ func s256(verifier string) string { sum := sha256.Sum256([]byte(verifier)) return base64.RawURLEncoding.EncodeToString(sum[:]) } + +// testLoginTemplates builds the two-template collection the login-template edge +// tests share: admin-alice with claims, basic-bob without. +func testLoginTemplates(t *testing.T) oidc.LoginTemplates { + t.Helper() + + var claims oidc.CustomClaims + claims.Set("email", "alice@example.com") + admin, err := oidc.NewLoginTemplate("admin-alice", "alice", claims) + require.NoError(t, err) + basic, err := oidc.NewLoginTemplate("basic-bob", "bob", oidc.CustomClaims{}) + require.NoError(t, err) + + templates, err := oidc.NewLoginTemplates(admin, basic) + require.NoError(t, err) + return templates +} + +// TestAuthorizeLoginPageRendersTemplateDropdown verifies the login page offers +// the configured templates as a pre-fill dropdown: an option per template +// carrying the subject as its value and the claims JSON (attribute-escaped) in +// data-claims, plus the pre-fill script — and that the dropdown is absent when +// no templates are configured. +func TestAuthorizeLoginPageRendersTemplateDropdown(t *testing.T) { + t.Parallel() + + authorizeQuery := "/default/authorize?response_type=code&client_id=app&redirect_uri=https://client.example/cb&state=xyz" + + t.Run("with templates", func(t *testing.T) { + t.Parallel() + + srv := newAuthServer(t, true, testLoginTemplates(t)) + resp, body := doGet(t, srv, authorizeQuery) + + require.Equal(t, http.StatusOK, resp.StatusCode) + html := string(body) + assert.Contains(t, html, `id="tmpl-select"`, "template dropdown rendered") + assert.Contains(t, html, `>admin-alice`, "template name is the option text") + assert.Contains(t, html, `value="alice"`, "subject rides the option value") + assert.Contains(t, html, `value="bob"`) + assert.Contains(t, html, `data-claims="{"email":"alice@example.com"}"`, + "claims JSON attribute-escaped on the option") + assert.Contains(t, html, `data-claims=""`, "claimless template carries an empty pre-fill") + assert.Contains(t, html, "tmpl-select')", "pre-fill script present") + }) + + t.Run("without templates", func(t *testing.T) { + t.Parallel() + + srv := newAuthServer(t, true) + resp, body := doGet(t, srv, authorizeQuery) + + require.Equal(t, http.StatusOK, resp.StatusCode) + assert.NotContains(t, string(body), "tmpl-select", "no dropdown without templates") + }) +} + +// TestAuthorizeLoginHintHeadlessIssuesCode verifies the headless path end to +// end: a known login_hint bypasses the login page even with interactive login +// forced, and the redeemed tokens carry the template's subject and claims. +func TestAuthorizeLoginHintHeadlessIssuesCode(t *testing.T) { + t.Parallel() + + srv := newAuthServer(t, true, testLoginTemplates(t)) + resp := getNoRedirect( + t, + srv, + "/default/authorize?response_type=code&client_id=app&redirect_uri=https://client.example/cb&state=hl1&login_hint=admin-alice", + ) + + require.Equal(t, http.StatusFound, resp.StatusCode, "hint bypasses the login page") + loc, err := url.Parse(resp.Header.Get("Location")) + require.NoError(t, err) + code := loc.Query().Get("code") + require.NotEmpty(t, code, "code issued headlessly") + assert.Equal(t, "hl1", loc.Query().Get("state")) + + tokResp, body := postForm(t, srv, "grant_type=authorization_code&code="+code+"&client_id=app") + require.Equal(t, http.StatusOK, tokResp.StatusCode, "body: %s", body) + var tok struct { + IDToken string `json:"id_token"` + } + require.NoError(t, json.Unmarshal(body, &tok)) + claims := verifyRS256(t, tok.IDToken, fetchPublicKey(t, srv)) + assert.Equal(t, "alice", claims["sub"], "template subject") + assert.Equal(t, "alice@example.com", claims["email"], "template claim folded in") +} + +// TestAuthorizeLoginHintUnknown verifies an unknown template name is a hard +// invalid_request on both error surfaces: redirected into a usable redirect_uri, +// and rendered as the direct HTML error page when redirect_uri is absent. +func TestAuthorizeLoginHintUnknown(t *testing.T) { + t.Parallel() + + t.Run("redirects the error into redirect_uri", func(t *testing.T) { + t.Parallel() + + srv := newAuthServer(t, false, testLoginTemplates(t)) + resp := getNoRedirect( + t, + srv, + "/default/authorize?response_type=code&client_id=app&redirect_uri=https://client.example/cb&state=e1&login_hint=nobody", + ) + + require.Equal(t, http.StatusFound, resp.StatusCode) + loc, err := url.Parse(resp.Header.Get("Location")) + require.NoError(t, err) + assert.Equal(t, "invalid_request", loc.Query().Get("error")) + assert.Contains(t, loc.Query().Get("error_description"), "nobody") + assert.Empty(t, loc.Query().Get("code"), "no code on the error path") + }) + + t.Run("renders the direct error page without redirect_uri", func(t *testing.T) { + t.Parallel() + + srv := newAuthServer(t, false, testLoginTemplates(t)) + resp, body := doGet(t, srv, + "/default/authorize?response_type=code&client_id=app&login_hint=nobody") + + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) + assert.Contains(t, string(body), "invalid_request") + }) +} + +// TestAuthorizeLoginHintFormPost pins the hint + response_mode=form_post +// combination: the headless resolution still honors form_post and returns the +// auto-submit page instead of a 302. +func TestAuthorizeLoginHintFormPost(t *testing.T) { + t.Parallel() + + srv := newAuthServer(t, true, testLoginTemplates(t)) + resp, body := doGet( + t, + srv, + "/default/authorize?response_type=code&client_id=app&redirect_uri=https://client.example/cb&state=fp2&response_mode=form_post&login_hint=basic-bob", + ) + + require.Equal(t, http.StatusOK, resp.StatusCode) + html := string(body) + assert.Contains(t, html, `action="https://client.example/cb"`, "auto-submit posts to redirect_uri") + assert.Contains(t, html, `name="code"`, "carries the code") + assert.Contains(t, html, `value="fp2"`, "carries the state") +} diff --git a/internal/oidc/httpapi/html.go b/internal/oidc/httpapi/html.go index ec7ed7a..1aedfaf 100644 --- a/internal/oidc/httpapi/html.go +++ b/internal/oidc/httpapi/html.go @@ -46,10 +46,23 @@ type BrowserOutput struct { Body []byte } +// loginTemplateView is one login-template dropdown entry: the display name, the +// subject the selection pre-fills into the username field, and the claim set +// pre-serialized as a JSON object for the claims textarea (empty when the +// template has no claims). All three render through html/template's attribute +// auto-escaping; the browser un-escapes on read, so the pre-fill round-trips. +type loginTemplateView struct { + Name string + Username string + ClaimsJSON string +} + // loginData is the login.html model: the action URL the form POSTs back to -// (the same /authorize URL, query string preserved). +// (the same /authorize URL, query string preserved), plus the configured login +// templates (nil/empty ⇒ the dropdown is not rendered). type loginData struct { - Action string + Action string + Templates []loginTemplateView } // formPostData is the form_post.html model: the self-submitting form posts ONLY diff --git a/internal/oidc/httpapi/html/login.html b/internal/oidc/httpapi/html/login.html index 11c1ebb..de405b0 100644 --- a/internal/oidc/httpapi/html/login.html +++ b/internal/oidc/httpapi/html/login.html @@ -15,7 +15,7 @@ h1 { margin: 0 0 .25rem; font-size: 1.4rem; } .notice { margin: 0 0 1.25rem; font-size: .8rem; line-height: 1.4; color: #fbbf24; } label { display: block; margin-bottom: 1rem; font-size: .85rem; font-weight: 600; } - input[type=text], textarea { width: 100%; margin-top: .35rem; padding: .6rem .7rem; font-size: .95rem; + input[type=text], textarea, select { width: 100%; margin-top: .35rem; padding: .6rem .7rem; font-size: .95rem; font-weight: 400; border: 1px solid #475569; border-radius: 8px; background: #0f172a; color: #e2e8f0; } textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; } button { width: 100%; margin-top: .5rem; padding: .7rem; font-size: 1rem; font-weight: 600; @@ -28,6 +28,16 @@

mock-oidc sign-in

FOR TESTING ONLY — this server mints signed tokens for whatever identity you enter.

+ {{if .Templates}} + + {{end}} @@ -37,5 +47,20 @@

mock-oidc sign-in

+{{if .Templates}} + +{{end}} diff --git a/internal/oidc/httpapi/httpapi_test.go b/internal/oidc/httpapi/httpapi_test.go index d55bf01..c09b863 100644 --- a/internal/oidc/httpapi/httpapi_test.go +++ b/internal/oidc/httpapi/httpapi_test.go @@ -42,10 +42,16 @@ func newTestServer(t *testing.T) *httptest.Server { // FallbackWriter strategy so wrong-method protocol routes render the OAuth2 shape. // The CodeStore is shared between the AuthorizeService and the TokenService so a // code minted at /authorize is redeemable at /token. interactive forces the login -// page on GET /authorize. -func newAuthServer(t *testing.T, interactive bool) *httptest.Server { +// page on GET /authorize. An optional trailing LoginTemplates wires the login +// templates into both the AuthorizeService and the login-page render. +func newAuthServer(t *testing.T, interactive bool, templates ...oidc.LoginTemplates) *httptest.Server { t.Helper() + var loginTemplates oidc.LoginTemplates + if len(templates) > 0 { + loginTemplates = templates[0] + } + signer, err := signing.NewProvider(oidc.RS256, nil) require.NoError(t, err) @@ -56,9 +62,13 @@ func newAuthServer(t *testing.T, interactive bool) *httptest.Server { provider := oidc.NewProviderService(registry, signer) tokens := oidc.NewTokenService(registry, signer, signer, clock, oidc.WithCodeStore(codes), oidc.WithRefreshStore(refresh)) - authorize := oidc.NewAuthorizeService(codes, clock, interactive) + authorize := oidc.NewAuthorizeService(codes, clock, interactive, + oidc.WithLoginTemplates(loginTemplates)) session := oidc.NewSessionService(signer, refresh, clock) - deps := httpapi.Deps{Provider: provider, Tokens: tokens, Authorize: authorize, Session: session} + deps := httpapi.Deps{ + Provider: provider, Tokens: tokens, Authorize: authorize, Session: session, + LoginTemplates: loginTemplates, + } discard := observability.NewLogger(io.Discard, slog.LevelError, "json") handler := adapterhttp.NewRouter(adapterhttp.RouterDeps{ diff --git a/internal/oidc/httpapi/login.go b/internal/oidc/httpapi/login.go index c90f332..b69cb63 100644 --- a/internal/oidc/httpapi/login.go +++ b/internal/oidc/httpapi/login.go @@ -29,6 +29,7 @@ type LoginInput struct { Prompt string `query:"prompt"` CodeChallenge string `query:"code_challenge"` CodeChallengeMethod string `query:"code_challenge_method"` + LoginHint string `query:"login_hint"` RawBody []byte `contentType:"application/x-www-form-urlencoded"` } @@ -47,6 +48,7 @@ func (i *LoginInput) params() authorizeParams { Prompt: i.Prompt, CodeChallenge: i.CodeChallenge, CodeChallengeMethod: i.CodeChallengeMethod, + LoginHint: i.LoginHint, } } @@ -139,6 +141,40 @@ func parseLoginClaims(raw string) (oidc.CustomClaims, bool) { return claims, true } +// claimsToJSON serializes an ordered claim set into a compact JSON object for +// the login-page template pre-fill — the inverse of parseLoginClaims. The object +// is built member-by-member so insertion order survives (marshaling a map would +// randomize it); [json.Marshal] escapes <, >, and & by default, so the result is +// safe for the attribute context it renders into. Empty claims yield "" (a blank +// textarea), and a marshal failure — unreachable for config-parsed, JSON-native +// values — degrades to "" rather than a partial object. +func claimsToJSON(claims oidc.CustomClaims) string { + entries := claims.Entries() + if len(entries) == 0 { + return "" + } + var buf strings.Builder + buf.WriteByte('{') + for i, e := range entries { + if i > 0 { + buf.WriteByte(',') + } + name, err := json.Marshal(e.Name) + if err != nil { + return "" + } + value, err := json.Marshal(e.Value) + if err != nil { + return "" + } + buf.Write(name) + buf.WriteByte(':') + buf.Write(value) + } + buf.WriteByte('}') + return buf.String() +} + // loginFormSchema documents the url-encoded login-submission fields for the // OpenAPI document (username required; optional claims JSON blob). func loginFormSchema() map[string]*huma.Schema { diff --git a/internal/oidc/httpapi/registrar.go b/internal/oidc/httpapi/registrar.go index c62cafb..d1dd7a8 100644 --- a/internal/oidc/httpapi/registrar.go +++ b/internal/oidc/httpapi/registrar.go @@ -81,6 +81,11 @@ type Deps struct { // browser-facing origin, so it stays reachable behind a remapped/mapped container // port. Empty preserves the origin-derived dial (in-process httptest tests). SelfAddr string + // LoginTemplates are the config-declared login templates the login page offers + // as a pre-fill dropdown. The rendering is an adapter concern; the domain + // consumes the same collection separately for headless login_hint resolution. + // Empty leaves the login page dropdown-free. + LoginTemplates oidc.LoginTemplates } // handlers binds the dependencies for the operation handler methods. diff --git a/internal/oidc/logintemplate.go b/internal/oidc/logintemplate.go new file mode 100644 index 0000000..0ac042d --- /dev/null +++ b/internal/oidc/logintemplate.go @@ -0,0 +1,114 @@ +package oidc + +import ( + "errors" + "fmt" + "strings" +) + +// LoginTemplateName is the config-declared template identifier — the value a +// headless client sends as login_hint. It is a distinct type from Subject so a +// template lookup key is never confused with the identity it resolves to. +type LoginTemplateName string + +// LoginTemplate is one named, config-declared principal: selecting it (via the +// login-page dropdown or a login_hint) logs in as Subject with Claims. It is +// deliberately just {name, subject, claims} — no token overrides; those remain +// the domain of token callbacks. +type LoginTemplate struct { + Name LoginTemplateName + Subject Subject + Claims CustomClaims +} + +// Config-time login-template sentinels for [errors.Is]. They surface at startup +// via the JSON-config loader (fail-fast), never on a request path. +var ( + ErrBlankTemplateName = errors.New("login template name must not be blank") + ErrBlankTemplateSubject = errors.New("login template subject must not be blank") + ErrDuplicateTemplateName = errors.New("duplicate login template name") +) + +// NewLoginTemplate validates the config-declared template fields: a blank +// (or whitespace-only) name or subject is rejected with the matching sentinel. +// These are config-time errors, not *ProtocolError — a bad template aborts +// startup rather than answering a request. +func NewLoginTemplate(name string, subject Subject, claims CustomClaims) (LoginTemplate, error) { + if strings.TrimSpace(name) == "" { + return LoginTemplate{}, ErrBlankTemplateName + } + if strings.TrimSpace(string(subject)) == "" { + return LoginTemplate{}, ErrBlankTemplateSubject + } + return LoginTemplate{Name: LoginTemplateName(name), Subject: subject, Claims: claims}, nil +} + +// Submission resolves the template into the LoginSubmission the authorize +// pipeline already understands, so template claims inherit login-claim +// semantics (merged putIfAbsent at mint; a mapping/registered claim wins). +// Claims are cloned so per-request merges never mutate the shared template. +func (t LoginTemplate) Submission() LoginSubmission { + return LoginSubmission{Username: t.Subject, Claims: t.Claims.Clone()} +} + +// LoginTemplates is the ordered, name-indexed template collection. The zero +// value is valid and empty — an empty collection turns the feature off (the +// login_hint branch is skipped and the dropdown is not rendered). Uniqueness of +// names is a construction invariant (NewLoginTemplates), so Lookup is +// unambiguous by parse-don't-validate. +type LoginTemplates struct { + order []LoginTemplateName + byName map[LoginTemplateName]LoginTemplate +} + +// NewLoginTemplates builds the collection preserving declaration order and +// rejecting duplicate names (wrapping ErrDuplicateTemplateName with the +// offending name). +func NewLoginTemplates(templates ...LoginTemplate) (LoginTemplates, error) { + if len(templates) == 0 { + return LoginTemplates{}, nil + } + ts := LoginTemplates{ + order: make([]LoginTemplateName, 0, len(templates)), + byName: make(map[LoginTemplateName]LoginTemplate, len(templates)), + } + for _, t := range templates { + if _, dup := ts.byName[t.Name]; dup { + return LoginTemplates{}, fmt.Errorf("%w: %q", ErrDuplicateTemplateName, string(t.Name)) + } + ts.order = append(ts.order, t.Name) + ts.byName[t.Name] = t + } + return ts, nil +} + +// Lookup returns the template registered under name, reporting a miss with ok. +func (ts LoginTemplates) Lookup(name LoginTemplateName) (LoginTemplate, bool) { + t, ok := ts.byName[name] + return t, ok +} + +// Len reports the number of configured templates; zero means the feature is off. +func (ts LoginTemplates) Len() int { return len(ts.order) } + +// All returns the templates in declaration order — the login-page render +// accessor. The returned slice is freshly allocated; callers may not reach the +// internal state through it. +func (ts LoginTemplates) All() []LoginTemplate { + if len(ts.order) == 0 { + return nil + } + all := make([]LoginTemplate, 0, len(ts.order)) + for _, name := range ts.order { + all = append(all, ts.byName[name]) + } + return all +} + +// UnknownLoginTemplate reports a login_hint that names no configured template +// as invalid_request (400). It is a hard error by design: when templates are +// configured, a typo'd hint failing loudly beats an automated test silently +// receiving a login page or a default-subject token. +func UnknownLoginTemplate(name string) *ProtocolError { + return MalformedRequest(fmt.Sprintf("login_hint %q does not match a configured login template", name)) +} diff --git a/internal/oidc/logintemplate_test.go b/internal/oidc/logintemplate_test.go new file mode 100644 index 0000000..e1bac19 --- /dev/null +++ b/internal/oidc/logintemplate_test.go @@ -0,0 +1,113 @@ +package oidc_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/meigma/mock-oidc/internal/oidc" +) + +// TestNewLoginTemplateValidation covers the config-time field checks: blank or +// whitespace-only names and subjects are rejected with their sentinels. +func TestNewLoginTemplateValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tmplName string + subject oidc.Subject + wantErr error + }{ + {"valid", "admin-alice", "alice", nil}, + {"blank name", "", "alice", oidc.ErrBlankTemplateName}, + {"whitespace name", " ", "alice", oidc.ErrBlankTemplateName}, + {"blank subject", "admin-alice", "", oidc.ErrBlankTemplateSubject}, + {"whitespace subject", "admin-alice", " ", oidc.ErrBlankTemplateSubject}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tmpl, err := oidc.NewLoginTemplate(tc.tmplName, tc.subject, oidc.CustomClaims{}) + if tc.wantErr != nil { + assert.ErrorIs(t, err, tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, oidc.LoginTemplateName(tc.tmplName), tmpl.Name) + assert.Equal(t, tc.subject, tmpl.Subject) + }) + } +} + +// TestNewLoginTemplatesRejectsDuplicates asserts the unique-name construction +// invariant, naming the offender in the error. +func TestNewLoginTemplatesRejectsDuplicates(t *testing.T) { + t.Parallel() + + a, err := oidc.NewLoginTemplate("dup", "alice", oidc.CustomClaims{}) + require.NoError(t, err) + b, err := oidc.NewLoginTemplate("dup", "bob", oidc.CustomClaims{}) + require.NoError(t, err) + + _, err = oidc.NewLoginTemplates(a, b) + require.ErrorIs(t, err, oidc.ErrDuplicateTemplateName) + assert.Contains(t, err.Error(), `"dup"`) +} + +// TestLoginTemplatesLookupAndOrder covers Lookup hit/miss, Len, and All +// preserving declaration order; the zero value is empty and misses safely. +func TestLoginTemplatesLookupAndOrder(t *testing.T) { + t.Parallel() + + b, err := oidc.NewLoginTemplate("basic-bob", "bob", oidc.CustomClaims{}) + require.NoError(t, err) + a, err := oidc.NewLoginTemplate("admin-alice", "alice", oidc.CustomClaims{}) + require.NoError(t, err) + templates, err := oidc.NewLoginTemplates(b, a) // declared bob-first on purpose + require.NoError(t, err) + + assert.Equal(t, 2, templates.Len()) + + got, ok := templates.Lookup("admin-alice") + require.True(t, ok) + assert.Equal(t, oidc.Subject("alice"), got.Subject) + _, ok = templates.Lookup("nobody") + assert.False(t, ok) + + all := templates.All() + require.Len(t, all, 2) + assert.Equal(t, oidc.LoginTemplateName("basic-bob"), all[0].Name) + assert.Equal(t, oidc.LoginTemplateName("admin-alice"), all[1].Name) + + var zero oidc.LoginTemplates + assert.Equal(t, 0, zero.Len()) + assert.Nil(t, zero.All()) + _, ok = zero.Lookup("admin-alice") + assert.False(t, ok) +} + +// TestLoginTemplateSubmissionClonesClaims guards the aliasing bug: mutating a +// resolved submission's claims must not leak back into the shared template. +func TestLoginTemplateSubmissionClonesClaims(t *testing.T) { + t.Parallel() + + var claims oidc.CustomClaims + claims.Set("email", "alice@example.com") + tmpl, err := oidc.NewLoginTemplate("admin-alice", "alice", claims) + require.NoError(t, err) + + sub := tmpl.Submission() + assert.Equal(t, oidc.Subject("alice"), sub.Username) + sub.Claims.Set("email", "tampered@example.com") + sub.Claims.Set("extra", true) + + orig, ok := tmpl.Claims.Get("email") + require.True(t, ok) + assert.Equal(t, "alice@example.com", orig, "template claims must be unaffected by submission mutation") + _, ok = tmpl.Claims.Get("extra") + assert.False(t, ok) +} diff --git a/webtest/config.container.json b/webtest/config.container.json index b0670b6..54a52e5 100644 --- a/webtest/config.container.json +++ b/webtest/config.container.json @@ -2,6 +2,20 @@ "interactiveLogin": false, "rotateRefreshToken": false, "staticAssetsPath": "/webtest", + "loginTemplates": [ + { + "name": "admin-alice", + "subject": "template-alice", + "claims": { + "email": "template-alice@example.com", + "template_marker": "from-template" + } + }, + { + "name": "basic-bob", + "subject": "template-bob" + } + ], "tokenProvider": { "keyProvider": { "algorithm": "RS256" diff --git a/webtest/sections/authorize.js b/webtest/sections/authorize.js index dabfcfb..64a0c95 100644 --- a/webtest/sections/authorize.js +++ b/webtest/sections/authorize.js @@ -183,6 +183,75 @@ export default { return ctx.pass({ expected: 'code + callback.html (no login page)', actual: 'code issued, landed on callback.html', detail: { req } }); }, }, + { + name: 'login_hint=admin-alice + prompt=login → headless code; id_token carries the template identity', + async run(ctx) { + const state = ctx.newState(); + // prompt=login would normally force the login page; the template hint + // must win and issue the code headlessly. + const { res, req } = await doAuthorize(ctx, { + response_type: 'code', + client_id: ctx.cfg.clientId, + scope: 'openid profile', + state, + prompt: 'login', + login_hint: 'admin-alice', + }); + const code = res.params && res.params.code; + if (!code) { + return ctx.fail('headless code despite prompt=login', 'no code (' + JSON.stringify(res.params) + ')', { req, raw: trunc(res.text) }); + } + const tok = await ctx.api.token(ctx.cfg.issuer, { + grant_type: 'authorization_code', + code, + redirect_uri: ctx.api.base + '/static/callback.html', + client_id: ctx.cfg.clientId, + }); + if (tok.status !== 200 || !tok.json || !tok.json.id_token) { + return ctx.fail('200 + id_token', 'HTTP ' + tok.status, { req, raw: trunc(tok.text) }); + } + let payload; + try { + payload = ctx.jwt.decode(tok.json.id_token).payload; + } catch (e) { + return ctx.fail('decodable id_token', String((e && e.message) || e), { req, raw: trunc(tok.text) }); + } + if (payload.sub !== 'template-alice' || payload.template_marker !== 'from-template') { + return ctx.fail( + 'sub=template-alice + template_marker=from-template', + 'sub=' + String(payload.sub) + ' template_marker=' + String(payload.template_marker), + { req, raw: trunc(tok.text) }, + ); + } + return ctx.pass({ + expected: 'template identity minted headlessly', + actual: 'sub=' + payload.sub + ', email=' + String(payload.email) + ', marker=' + payload.template_marker, + detail: { req }, + }); + }, + }, + { + name: 'login_hint=unknown → error=invalid_request at redirect_uri, no code', + async run(ctx) { + const state = ctx.newState(); + const { res, req } = await doAuthorize(ctx, { + response_type: 'code', + client_id: ctx.cfg.clientId, + scope: 'openid profile', + state, + login_hint: 'no-such-template', + }); + const err = res.params && res.params.error; + const code = res.params && res.params.code; + if (code) { + return ctx.fail('no code for an unknown template', 'code issued', { req, raw: trunc(res.text) }); + } + if (err !== 'invalid_request') { + return ctx.fail('error=invalid_request', 'error=' + String(err) + ' (params=' + JSON.stringify(res.params) + ')', { req, raw: trunc(res.text) }); + } + return ctx.pass({ expected: 'invalid_request, no code', actual: 'error=invalid_request delivered to redirect_uri', detail: { req } }); + }, + }, { name: 'GET /favicon.ico → 200', async run(ctx) { @@ -202,10 +271,12 @@ export default { name: 'Interactive login page mints an id_token for the entered identity', instructions: 'Click Start: a popup opens the login page at /{issuer}/authorize with prompt=login ' + - '(scope openid profile, fresh state+nonce, redirect_uri=/static/callback.html). In the popup, ' + - 'enter username alice and Additional claims {"email":"alice@example.com"}, then Sign in. The popup ' + - 'lands on callback.html and posts the code back; Start exchanges it at /token and reports the ' + - 'id_token sub + email. PASS when sub is "alice" and email is "alice@example.com".', + '(scope openid profile, fresh state+nonce, redirect_uri=/static/callback.html). The page shows a ' + + 'Template dropdown (admin-alice / basic-bob) — pick one and confirm it PRE-FILLS the username and ' + + 'claims fields and that both stay editable. Then enter username alice and Additional claims ' + + '{"email":"alice@example.com"} (overwriting any pre-fill), and Sign in. The popup lands on ' + + 'callback.html and posts the code back; Start exchanges it at /token and reports the id_token ' + + 'sub + email. PASS when sub is "alice" and email is "alice@example.com".', async start(ctx) { const base = ctx.api.base; const issuer = ctx.cfg.issuer;