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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Treat this like [tenant-shard-db](https://github.com/elloloop/tenant-shard-db):
- **Passkeys (WebAuthn)** registration and login
- **TOTP (2FA)** setup, verify, recovery codes
- **QR cross-device login**
- **OAuth login** (Google, Microsoft — server consumes pre-verified ID tokens from frontend SDKs)
- **OAuth login** (Google, Microsoft, GitHub, Apple — server-owned authorization-code exchange)
- **Sessions** with revoke and sign-out-everywhere
- **JWT issuance** with key rotation, plus `/.well-known/jwks.json` for downstream services
- **User and Group CRUD**, group membership data
Expand Down
38 changes: 25 additions & 13 deletions docs-site/src/pages/docs/auth/oauth.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Code } from "astro-expressive-code/components";
<DocsLayout title="OAuth">
<h1>OAuth</h1>
<p>
Identity supports Google, Microsoft, and GitHub as upstream OAuth
Identity supports Google, Microsoft, GitHub, and Apple as upstream OAuth
providers. The flow is server-owned: the client calls
<code>BeginOAuthLogin</code>, redirects the browser to the returned
provider URL, then completes the callback with <code>OAuthLogin</code>.
Expand All @@ -25,16 +25,27 @@ import { Code } from "astro-expressive-code/components";

<h2>Configuration</h2>
<p>Configure each provider you intend to support:</p>
<Code code={`-e GATEWAY_GOOGLE_CLIENT_ID=...
-e GATEWAY_GOOGLE_CLIENT_SECRET=...
-e GATEWAY_MICROSOFT_CLIENT_ID=...
-e GATEWAY_MICROSOFT_CLIENT_SECRET=...
-e GATEWAY_MICROSOFT_TENANT_ID=common`} lang="bash" />
<Code code={`-e GATEWAY_OAUTH_GOOGLE_CLIENT_ID=...
-e GATEWAY_OAUTH_GOOGLE_CLIENT_SECRET=...
-e GATEWAY_OAUTH_MICROSOFT_CLIENT_ID=...
-e GATEWAY_OAUTH_MICROSOFT_CLIENT_SECRET=...
-e GATEWAY_MICROSOFT_TENANT_ID=common
-e GATEWAY_OAUTH_GITHUB_CLIENT_ID=...
-e GATEWAY_OAUTH_GITHUB_CLIENT_SECRET=...
-e GATEWAY_OAUTH_APPLE_CLIENT_ID=...
-e GATEWAY_OAUTH_APPLE_TEAM_ID=...
-e GATEWAY_OAUTH_APPLE_KEY_ID=...
-e GATEWAY_OAUTH_APPLE_PRIVATE_KEY="..."`} lang="bash" />

<div class="rounded-lg border border-border bg-muted/30 p-4 text-sm mt-4">
<strong>Apple Configuration.</strong> Apple requires a Team ID, a Services ID (used as the Client ID), a Key ID, and an ECDSA private key (PEM or Base64).
Unlike other providers, Apple sends this headless flow's callback via <code>POST</code>. Configure an application-owned HTTPS callback that accepts <code>code</code>, <code>state</code>, and the optional <code>user</code> payload, then forwards them to <code>OAuthLogin</code>. Configure <code>https://your-api.com/oauth/callback/apple</code> only when using the hosted flow below.
</div>

<h2>Start RPC</h2>
<Code code={`message BeginOAuthLoginRequest {
string redirect_uri = 1;
string provider = 2; // "google", "microsoft", or "github"
string provider = 2; // "google", "microsoft", "github", or "apple"
string tenant = 3; // Microsoft tenant ID (optional)
}

Expand All @@ -50,11 +61,12 @@ message BeginOAuthLoginResponse {
<Code code={`message OAuthLoginRequest {
string code = 1; // authorization code from provider
string redirect_uri = 2;
string provider = 3; // "google", "microsoft", or "github"
string provider = 3; // "google", "microsoft", "github", or "apple"
string code_verifier = 4; // optional when state_token is present
string tenant = 5; // Microsoft tenant ID (optional)
string state = 6; // callback state from the provider redirect
string state_token = 7; // opaque token minted by BeginOAuthLogin
string apple_user_payload = 8; // one-time user payload from Apple's form_post callback
}`} lang="proto" />

<h2>Response</h2>
Expand Down Expand Up @@ -134,8 +146,8 @@ message BeginOAuthLoginResponse {
redirects the browser to it.
</li>
<li>
<code>GET /oauth/callback/{`{provider}`}</code> — receives the
provider's authorization code, exchanges it through the same
<code>GET/POST /oauth/callback/{`{provider}`}</code> — receives the
provider's authorization code (via URL query or POST form body), exchanges it through the same
<code>OAuthLogin</code> internals, mints an opaque single-use code
(<code>OAuthOneTimeCode</code>, 60s TTL), and 302-redirects to
<code>return_to?code=&lt;otc&gt;</code>.
Expand All @@ -152,12 +164,12 @@ message BeginOAuthLoginResponse {
<h3>Enabling the hosted flow</h3>
<p>
The hosted flow is <strong>off by default</strong>. Enable it by
setting an allowlist of <code>return_to</code> origins / prefixes:
setting an allowlist of <code>return_to</code> origins or origin-bound path prefixes:
</p>
<Code code={`-e GATEWAY_OAUTH_ALLOWED_RETURN_URLS=https://app.example.com,https://staging.example.com`} lang="bash" />
<p>
A <code>return_to</code> must equal or be prefixed by an allowlist
entry; anything else is rejected with HTTP 400 at
A <code>return_to</code> must match an allowlist origin. A path entry
permits that path and its descendants; anything else is rejected with HTTP 400 at
<code>/oauth/start</code> before any provider round-trip. Empty (the
default) disables the routes entirely — they return 404 and only the
headless RPCs work. The single provider-facing redirect URI registered
Expand Down
8 changes: 4 additions & 4 deletions docs-site/src/pages/docs/deployment/kubernetes.astro
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ stringData:
GATEWAY_JWT_KEYS: |
[{"kid":"k-2026-05","private_key_pem":"-----BEGIN RSA PRIVATE KEY-----\\n...\\n-----END RSA PRIVATE KEY-----\\n","active":true}]
GATEWAY_TOTP_ENCRYPTION_KEY: "<base64 32-byte key>"
GATEWAY_GOOGLE_CLIENT_SECRET: "..."
GATEWAY_MICROSOFT_CLIENT_SECRET: "..."`} lang="yaml" title="secret.yaml" />
GATEWAY_OAUTH_GOOGLE_CLIENT_SECRET: "..."
GATEWAY_OAUTH_MICROSOFT_CLIENT_SECRET: "..."`} lang="yaml" title="secret.yaml" />

<h2>ConfigMap</h2>
<Code code={`apiVersion: v1
Expand All @@ -42,8 +42,8 @@ data:
GATEWAY_ALLOWED_ORIGINS: "https://acme.example.com"
GATEWAY_AUTH_ALLOW_LOCAL: "true"
GATEWAY_COOKIE_SECURE: "true"
GATEWAY_GOOGLE_CLIENT_ID: "..."
GATEWAY_MICROSOFT_CLIENT_ID: "..."`} lang="yaml" title="configmap.yaml" />
GATEWAY_OAUTH_GOOGLE_CLIENT_ID: "..."
GATEWAY_OAUTH_MICROSOFT_CLIENT_ID: "..."`} lang="yaml" title="configmap.yaml" />

<h2>Deployment</h2>
<Code code={`apiVersion: apps/v1
Expand Down
14 changes: 10 additions & 4 deletions docs-site/src/pages/docs/installation/configuration.astro
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,17 @@ import { Code } from "astro-expressive-code/components";
<h2>OAuth</h2>
<table>
<tr><th>Variable</th><th>Default</th><th>Purpose</th></tr>
<tr><td><code>GATEWAY_GOOGLE_CLIENT_ID</code></td><td><em>(empty)</em></td><td>Google OAuth client ID</td></tr>
<tr><td><code>GATEWAY_GOOGLE_CLIENT_SECRET</code></td><td><em>(empty)</em></td><td>Google OAuth client secret</td></tr>
<tr><td><code>GATEWAY_MICROSOFT_CLIENT_ID</code></td><td><em>(empty)</em></td><td>Microsoft / Entra ID client ID</td></tr>
<tr><td><code>GATEWAY_MICROSOFT_CLIENT_SECRET</code></td><td><em>(empty)</em></td><td>Microsoft client secret</td></tr>
<tr><td><code>GATEWAY_OAUTH_GOOGLE_CLIENT_ID</code></td><td><em>(empty)</em></td><td>Google OAuth client ID</td></tr>
<tr><td><code>GATEWAY_OAUTH_GOOGLE_CLIENT_SECRET</code></td><td><em>(empty)</em></td><td>Google OAuth client secret</td></tr>
<tr><td><code>GATEWAY_OAUTH_MICROSOFT_CLIENT_ID</code></td><td><em>(empty)</em></td><td>Microsoft / Entra ID client ID</td></tr>
<tr><td><code>GATEWAY_OAUTH_MICROSOFT_CLIENT_SECRET</code></td><td><em>(empty)</em></td><td>Microsoft client secret</td></tr>
<tr><td><code>GATEWAY_MICROSOFT_TENANT_ID</code></td><td><em>(empty)</em></td><td>Microsoft tenant ID (or <code>common</code>)</td></tr>
<tr><td><code>GATEWAY_OAUTH_GITHUB_CLIENT_ID</code></td><td><em>(empty)</em></td><td>GitHub OAuth client ID</td></tr>
<tr><td><code>GATEWAY_OAUTH_GITHUB_CLIENT_SECRET</code></td><td><em>(empty)</em></td><td>GitHub OAuth client secret</td></tr>
<tr><td><code>GATEWAY_OAUTH_APPLE_CLIENT_ID</code></td><td><em>(empty)</em></td><td>Apple Service ID (Client ID)</td></tr>
<tr><td><code>GATEWAY_OAUTH_APPLE_TEAM_ID</code></td><td><em>(empty)</em></td><td>Apple Developer Team ID</td></tr>
<tr><td><code>GATEWAY_OAUTH_APPLE_KEY_ID</code></td><td><em>(empty)</em></td><td>Apple Private Key ID</td></tr>
<tr><td><code>GATEWAY_OAUTH_APPLE_PRIVATE_KEY</code></td><td><em>(empty)</em></td><td>Apple Private Key (PEM format or base64)</td></tr>
</table>

<h2>Password / lockout</h2>
Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/pages/docs/installation/docker.astro
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ docker pull ghcr.io/elloloop/identity:latest`} lang="bash" />
-e GATEWAY_TOTP_ENCRYPTION_KEY="$(openssl rand -base64 32)" \\
-e GATEWAY_JWT_KEYS="$(cat keyring.json)" \\
-e GATEWAY_ALLOWED_ORIGINS="https://acme.example.com" \\
-e GATEWAY_GOOGLE_CLIENT_ID=... \\
-e GATEWAY_GOOGLE_CLIENT_SECRET=... \\
-e GATEWAY_OAUTH_GOOGLE_CLIENT_ID=... \\
-e GATEWAY_OAUTH_GOOGLE_CLIENT_SECRET=... \\
-e GATEWAY_AUTH_ALLOW_LOCAL=true \\
ghcr.io/elloloop/identity:1.2.0`} lang="bash" title="production" />

Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Code } from "astro-expressive-code/components";
<li><strong>Email + password</strong> — signup, login, change, recovery-email reset, lockout</li>
<li><strong>Passkeys (WebAuthn)</strong> — registration and login flows</li>
<li><strong>TOTP (2FA)</strong> — setup, verify, recovery codes</li>
<li><strong>OAuth login</strong> — Google, Microsoft (server consumes pre-verified tokens from frontend SDKs)</li>
<li><strong>OAuth login</strong> — Google, Microsoft, GitHub, Apple (server-owned authorization-code exchange)</li>
<li><strong>QR cross-device login</strong> — initiate on a new device, approve from a logged-in one</li>
<li><strong>JWT issuance</strong> with key rotation, plus <code>/.well-known/jwks.json</code> for downstream services</li>
<li><strong>Sessions</strong> — list, revoke, sign-out-everywhere</li>
Expand Down
9 changes: 5 additions & 4 deletions docs/IDENTITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ before you ship.
added to the headless state token.

- **Hosted (new).** `GET /oauth/start/{provider}?return_to=` and
`GET /oauth/callback/{provider}` are plain `http.Handler` routes
`GET/POST /oauth/callback/{provider}` are plain `http.Handler` routes
(not Connect RPCs — the browser is 302-redirected through them).
They are thin wrappers over the same `BeginOAuthLogin` /
`OAuthLogin` service internals; there is no forked exchange path.
Expand Down Expand Up @@ -588,9 +588,10 @@ before you ship.

- **`return_to` allowlist, fail-closed, disabled by default.**
`GATEWAY_OAUTH_ALLOWED_RETURN_URLS` is a comma-separated list of
exact origins / URL prefixes. A `return_to` is allowed only if it
equals or is prefixed by an entry; anything else is rejected with
400 at `/oauth/start` before any provider round-trip. **Empty
exact origins / origin-bound path prefixes. A `return_to` must match
the configured origin and, for a path entry, that path or one of its
descendants; anything else is rejected with 400 at `/oauth/start`
before any provider round-trip. **Empty
disables the hosted flow entirely** — the `/oauth/*` routes are
not registered (404) and only the headless RPCs work. This is the
provider-facing-redirect allowlist that did not exist before:
Expand Down
32 changes: 16 additions & 16 deletions docs/oauth.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# OAuth login

identity supports OAuth/OIDC sign-in with Google, Microsoft, and GitHub.
identity supports OAuth/OIDC sign-in with Google, Microsoft, GitHub, and Apple.
It does the authorization-code exchange itself — the frontend is never
trusted to assert the user's identity. There are two flows; a deployer
can use either or both.
Expand All @@ -16,14 +16,14 @@ Both run against the same provider exchangers and token-minting code.

## Enabling providers

A provider is enabled only when **both** its client id and secret are
set. Leave a provider's credentials unset to disable it.
A provider is enabled when its required credentials are set (client id and secret, or Apple's private key and IDs). Leave a provider's credentials unset to disable it.

| Provider | Client ID env | Client secret env |
|-----------|--------------------------------------|------------------------------------------|
| Google | `GATEWAY_OAUTH_GOOGLE_CLIENT_ID` | `GATEWAY_OAUTH_GOOGLE_CLIENT_SECRET` |
| Microsoft | `GATEWAY_OAUTH_MICROSOFT_CLIENT_ID` | `GATEWAY_OAUTH_MICROSOFT_CLIENT_SECRET` |
| GitHub | `GATEWAY_OAUTH_GITHUB_CLIENT_ID` | `GATEWAY_OAUTH_GITHUB_CLIENT_SECRET` |
| Apple | `GATEWAY_OAUTH_APPLE_CLIENT_ID` | `GATEWAY_OAUTH_APPLE_PRIVATE_KEY` (along with TEAM_ID and KEY_ID) |

Microsoft also accepts `GATEWAY_MICROSOFT_TENANT_ID` (optional). At
startup identity logs the enabled providers (`oauth_providers_enabled`)
Expand All @@ -40,12 +40,12 @@ URLs identity may redirect users back to:
GATEWAY_OAUTH_ALLOWED_RETURN_URLS=https://app.example.com/,https://admin.example.com/auth
```

- Comma-separated list of exact origins or URL prefixes.
- A `return_to` is accepted only if it equals an entry or begins with
one (prefix match). Validation is **fail-closed**: anything else is
rejected with `400`.
- Comma-separated list of exact origins or origin-bound path prefixes.
- A `return_to` must have the configured origin. A path entry permits that
path and its descendants, never a lookalike host or path. Validation is
**fail-closed**: anything else is rejected with `400`.
- **Empty disables the hosted flow** — `GET /oauth/start/*` and
`GET /oauth/callback/*` return `404`, and only the headless RPCs work.
`GET/POST /oauth/callback/*` return `404`, and only the headless RPCs work.

The active allowlist is logged at startup
(`oauth_hosted_flow_enabled` / `oauth_hosted_flow_disabled`).
Expand Down Expand Up @@ -91,7 +91,7 @@ Browser identity Provider
| (user authenticates with provider) ---------------->|
| 302 -> /oauth/callback/google?state=<token>&code= |
|<----------------------------------------------------- |
| GET /oauth/callback/google?state=&code= |
| GET/POST /oauth/callback/google?state=&code= |
|----------------------->| |
| | verify state token |
| | exchange code (PKCE) |
Expand All @@ -109,8 +109,8 @@ Browser identity Provider
`return_to` against the allowlist, mints state + PKCE, binds
`return_to` into a signed hosted state token (tamper-proof), and
302-redirects the browser to the provider.
2. **`GET /oauth/callback/{provider}`** — the single registered redirect
URI. Recovers the state token, runs the code exchange + token mint,
2. **`GET/POST /oauth/callback/{provider}`** — the single registered redirect
URI. Apple uses `POST`; others use `GET`. Recovers the state token, runs the code exchange + token mint,
mints a single-use one-time code, and 302-redirects to
`return_to?code=<otc>`. On any failure it returns a generic `400`
(it cannot trust an unverified `return_to`) and logs server-side.
Expand Down Expand Up @@ -138,11 +138,11 @@ caller.
`{authorization_url, state, state_token, code_verifier, expires_in}`.
The frontend redirects the user to `authorization_url` (which uses
the frontend's own `redirect_uri`).
2. The provider redirects back to the frontend's callback page with
`?code=&state=`.
3. **`OAuthLogin{code, provider, redirect_uri, state, state_token}`** —
identity verifies the state token, exchanges the code, and returns
`{user, access_token, refresh_token, expires_in}`.
2. The provider redirects back to the frontend's callback page. Most providers
redirect via GET with `?code=&state=`. Apple redirects via HTTP POST (`form_post`)
with `code`, `state`, and an optional `user` JSON payload as form data.
3. **`OAuthLogin{code, provider, redirect_uri, state, state_token, apple_user_payload}`** —
identity supports server-owned authorization-code exchange. It does not consume pre-verified frontend SDK ID tokens. This guarantees you own the user relationship, keeps identity keys off the frontend, and enables robust refresh token flows.

The headless flow has no `return_to` allowlist: the frontend supplies
and owns its own `redirect_uri`, which it must register with the
Expand Down
Loading
Loading