Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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/),
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/explanation/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/how-to/drive-the-authorization-code-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions docs/docs/how-to/log-in-with-login-templates.md
Original file line number Diff line number Diff line change
@@ -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=<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=<template-name>` 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.
10 changes: 10 additions & 0 deletions docs/docs/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions docs/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {} }
}
```
Expand Down Expand Up @@ -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=<name>` 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
Expand Down
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 9 additions & 7 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions internal/config/jsonconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand Down
Loading