Appearance
Postmill ID (first-party SSO)
Postmill ID lets first-party Postmill apps — starting with the template store — offer "Sign in with Postmill" against any Postmill instance: Postmill Cloud or any self-hosted deployment. The app receives verified identity claims: user id, name, email, avatar, and the org the user was working in when they consented.
It is deliberately separate from OAuth Apps (which issue org-scoped pos_ API tokens to registered third-party clients). Postmill ID has no client registration and no client secrets: the first-party client is pinned in the product, and the code exchange is protected by PKCE alone.
How it works
- Every instance auto-generates an RS256 keypair on first use. The private key is stored encrypted at rest (
EncryptionService, AES-256-GCM) in theInstanceIdentitytable; the public key is published at the JWKS endpoint. No operator configuration is required — this is what makes self-hosted instances work out of the box. - The store asks the user for their instance URL, fetches the instance's discovery document, and starts an authorization-code + PKCE (S256) flow in the browser.
- The user logs in (if needed) and approves a consent screen on their own instance. The grant (
FederationGrant) is bound to the user and the org active at consent time — id, name, and the user's role in it are what the store receives. - The store exchanges the code at the instance's token endpoint (public client — PKCE verifier instead of a secret) and receives an RS256-signed
id_tokenplus a short-livedposf_access token for the userinfo endpoint. - The store verifies the
id_tokenagainst the instance's JWKS: signature,iss, fixed audiencepostmill-template-store,exp, and thenonceit sent.
Reachability requirement
The store backend must be able to reach the instance over HTTPS (token exchange + JWKS fetch). Self-hosted instances on private networks cannot use a cloud-hosted store — this is inherent to any SSO-with-self-hosted design.
Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
GET /.well-known/postmill-identity | none | Discovery document (endpoints, scopes, JWKS URI, fixed audience) |
GET /federation/jwks | none | Public keys for id_token verification |
GET /federation/authorize | none | Validates the request; drives the consent screen |
POST /federation/authorize | session + CSRF | User approve/deny (from the consent screen). Returns 201 |
POST /federation/token | PKCE | Code → id_token + posf_ access token (20 req/min throttle). Returns 201 |
GET/POST /federation/userinfo | Bearer posf_… | Scope-gated claims (1 h token lifetime) |
POST /federation/install-template | Bearer posf_… + templates:install scope | Install a .pdesign template into the granting org as a new design (20 req/min throttle). Returns 201 { designId, openUrl } |
The consent screen lives on the instance's frontend at /oauth/authorize?client=federation&redirect_uri=…&code_challenge=…&code_challenge_method=S256&scope=…&state=…&nonce=….
Two things that will bite a client implementer
Every call but the browser redirect must be server-side. CORS allows only this instance's own FRONTEND_URL, so a relying party's browser cannot reach /federation/token, /federation/userinfo, /federation/jwks or the discovery document. Fetch them from the relying party's backend.
Send no field the endpoint does not declare. The global validation pipe runs whitelist + forbidNonWhitelisted, so an extra body key on /federation/token — client_id and client_secret are the tempting ones, and neither exists in federation — is answered with a bare 400 {"statusCode":400,"message":[…],"error":"Bad Request"} rather than an OAuth-shaped invalid_request. The token body is exactly grant_type, code, redirect_uri and code_verifier.
Scopes and claims
Only these scopes exist in federation; anything else is rejected (invalid_scope).
| Scope | Grants |
|---|---|
profile | name, picture claims (avatar URL) |
email | email, email_verified claims (true when the account is activated) |
org | org: { id, name, role } claim — the consent-context org and the user's role key in it (owner/admin/editor/member/viewer) |
templates:install | Capability, no claims — authorizes POST /federation/install-template |
sub (the Postmill user id) is always present. The same scope gating applies to both the id_token claims and /federation/userinfo.
templates:install is the first write-capable scope. It is never granted by default: a client that omits scope receives the identity scopes only — the capability must be requested explicitly and appears on the consent screen as "Install templates into your Postmill designs". The granted scope string is persisted on the FederationGrant and re-checked on every install call, so revoking the grant (or its 1 h token expiring) closes the capability. See Template store integration for the install flow.
Login flow (store side)
text
1. User enters their instance URL (Postmill Cloud is pre-filled).
2. GET <instance>/.well-known/postmill-identity
3. Generate PKCE verifier + state + nonce; redirect the browser to
authorization_endpoint with redirect_uri, scope="profile email org",
code_challenge, code_challenge_method=S256, state, nonce.
4. On the callback, POST token_endpoint:
grant_type=authorization_code, code, redirect_uri, code_verifier
5. Verify id_token (RS256 against jwks_uri, iss, aud="postmill-template-store",
exp, nonce) and create the local session.
6. Optionally refresh profile data later via GET userinfo_endpoint with the
access token.Example token exchange:
bash
curl -X POST https://instance.example.com/federation/token \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "authorization_code",
"code": "…",
"redirect_uri": "https://templates.postmill.ai/auth/callback/postmill",
"code_verifier": "…"
}'json
{
"id_token": "eyJhbGciOiJSUzI1NiIs…",
"access_token": "posf_…",
"token_type": "bearer",
"expires_in": 3600,
"scope": "profile email org"
}Revocation
Users see active sign-ins under Settings → Approved apps → Postmill ID sign-ins and can revoke them there (GET /user/approved-apps/federation, DELETE /user/approved-apps/federation/:id). Revoking immediately invalidates the access token; outstanding id_tokens expire within an hour.
Instance configuration
| Variable | Default | Purpose |
|---|---|---|
FEDERATION_TRUSTED_REDIRECT_URIS | https://templates.postmill.ai/auth/callback/postmill | Comma-separated allow-list of redirect URIs codes may be sent to. Exact match. Setting it REPLACES the default rather than extending it, so listing a staging or dev URL means re-listing production too. |
FEDERATION_ISSUER | BACKEND_URL / NEXT_PUBLIC_BACKEND_URL | Explicit iss override (e.g. behind a path-rewriting proxy). |
To rotate the signing key, delete the InstanceIdentity row and restart the backend — the keypair is memoised in-process for the lifetime of the process, so deleting the row alone has no effect until then. The JWKS publishes a single key with no overlap window, so rotation invalidates outstanding id_tokens immediately (they expire within an hour anyway).
Verified against v1.3.0