From f857fa1da4ecb17134460f99c699bee43f26ed6c Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 25 Jun 2026 15:14:14 +0200 Subject: [PATCH] skills: update ePDS email-first OAuth guidance --- .agents/skills/epds-login/SKILL.md | 192 +++++++++++++----- .../skills/epds-login/references/dpop-pkce.md | 18 +- .agents/skills/epds-login/references/flows.md | 153 +++++++++----- 3 files changed, 252 insertions(+), 111 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 721f5652..5cd875b6 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -1,6 +1,6 @@ --- name: epds-login -description: Implement AT Protocol OAuth login against an ePDS instance. Covers two flows — Flow 1 (email-first, hand-rolled PAR/DPoP) and Flow 2 (via @atproto/oauth-client-node, accepting no hint / handle / DID). Use when building passwordless OTP login, configuring client metadata (confidential vs public), or integrating NodeOAuthClient. +description: Implement AT Protocol OAuth login against an ePDS instance. Covers email-first OTP with @atproto/oauth-client-browser or @atproto/oauth-client-node by appending login_hint to the returned authorize URL, plus hand-rolled PAR/DPoP only as a fallback. Use when building passwordless OTP login, configuring client metadata (confidential vs public), or integrating BrowserOAuthClient/NodeOAuthClient. --- # Implementing ePDS Login @@ -15,36 +15,107 @@ AT Protocol universe (a DID, a handle, a data repository) automatically provisio From your app's perspective, ePDS uses standard AT Protocol OAuth (PAR + PKCE + DPoP). The reference implementation is `packages/demo` in the [ePDS repository](https://github.com/hypercerts-org/ePDS). -## Two Flows +## Recommended Login Paths -| Flow | App provides | How user starts | Implementation | -| ---- | ----------------------- | ---------------------------- | -------------------- | -| 1 | Email address | OTP screen immediately | Hand-rolled PAR/DPoP | -| 2 | Nothing, handle, or DID | Depends on input (see below) | `NodeOAuthClient` | +| Path | App type | App provides | Implementation | +| -------------------- | --------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser OAuth client | Browser-only SPA or public client | PDS URL for email-first/no-identifier login, or handle/DID | `BrowserOAuthClient.authorize()`, append email `login_hint` and `prompt=login` to the returned authorize URL, then `init()` consumes the callback | +| Node OAuth client | Server app or confidential client | PDS URL for email-first/no-identifier login, or handle/DID | `NodeOAuthClient.authorize()`, append email `login_hint` and `prompt=login` to the returned authorize URL, then `callback()` consumes the callback | +| Hand-rolled fallback | Any runtime | Email address | Implement PAR, PKCE, DPoP, nonce retry, and token exchange yourself only if the OAuth client cannot expose the authorize URL or ePDS stops honoring URL-level hints | -**Why the split?** `@atproto/oauth-client-node`'s `authorize()` method accepts -a handle or DID as input but explicitly omits `login_hint` from its options — -the library resolves the identity itself and overrides the hint. Flow 1 needs -to pass a raw email as `login_hint` on the auth redirect URL (not in the PAR -body), which the library cannot do. Flow 1 must therefore use hand-rolled -PAR + DPoP requests. +`@atproto/oauth-client-browser` and `@atproto/oauth-client-node` both create the +PAR request, PKCE verifier, DPoP key, nonce retry, state, token exchange, and +session storage path for you. For email-first ePDS login, call `authorize()` +with the PDS URL and append the raw email to the returned `/oauth/authorize` +URL as `login_hint`. Do **not** put an email in the PAR body. -Flow 2 covers three input variants — all use the same `NodeOAuthClient` code: +Use these input variants with the same OAuth client code: -- **No identifier** — pass the PDS URL; auth server shows its own email form -- **Handle** — pass an AT Protocol handle (e.g. `alice.pds.example.com`); this fork shows the stock PDS handle/password page -- **DID** — pass a DID (e.g. `did:plc:abc123...`); same behaviour as handle +- **Email-first OTP** — pass the PDS URL to `authorize()`, then append `login_hint=` and `prompt=login` to the returned URL. +- **No identifier** — pass the PDS URL; auth server shows its own email form. +- **Handle** — pass an AT Protocol handle (e.g. `alice.pds.example.com`); this fork shows the stock PDS handle/password page. +- **DID** — pass a DID (e.g. `did:plc:abc123...`); same behaviour as handle. > **Important:** `login_hint` must **never** go in the PAR body when the value -> is an email address. The PDS core validates `login_hint` as an ATProto +> is an email address. The PDS core validates PAR `login_hint` as an ATProto > identity (handle or DID) and rejects emails with `Invalid login_hint`. Put > email `login_hint` only on the **auth redirect URL** — that request goes to > the ePDS auth service (Better Auth layer), which accepts emails. -## Quick Start — Flow 2 (recommended) +## Quick Start — BrowserOAuthClient -Use `@atproto/oauth-client-node` for any flow that does not require passing a -raw email as `login_hint`. +Use `@atproto/oauth-client-browser` for browser-only apps. It supports the +ePDS email-first pattern as long as you call `authorize()` yourself and redirect +after appending query parameters. Do not use `signInRedirect()` when you need to +add email `login_hint`, because it redirects before you can patch the URL. + +### 1. Client Metadata (public browser client) + +Host at your `client_id` URL. Browser-only apps normally use a public client: + +```json +{ + "client_id": "https://yourapp.example.com/client-metadata.json", + "client_name": "Your App", + "redirect_uris": ["https://yourapp.example.com/"], + "scope": "atproto transition:generic", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "dpop_bound_access_tokens": true +} +``` + +Public clients usually show consent on each login unless the PDS trusts the +client. For server apps, prefer the confidential `NodeOAuthClient` setup below. + +### 2. Create the browser OAuth client + +```typescript +import { BrowserOAuthClient } from '@atproto/oauth-client-browser' + +const client = await BrowserOAuthClient.load({ + clientId: 'https://yourapp.example.com/client-metadata.json', +}) +``` + +### 3. Login handler with optional email-first OTP + +```typescript +async function startLogin(input: { + pdsUrl: string + email?: string + forceLogin?: boolean +}) { + const url = await client.authorize(input.pdsUrl, { + scope: 'atproto transition:generic', + ...(input.forceLogin ? { prompt: 'login' } : {}), + }) + + if (input.email?.trim()) { + url.searchParams.set('login_hint', input.email.trim()) + } + if (input.forceLogin || input.email?.trim()) { + url.searchParams.set('prompt', 'login') + } + + window.location.assign(url.toString()) +} +``` + +### 4. Restore or consume the callback + +```typescript +const result = await client.init() +const session = result?.session +// session?.did — the user's DID +// session?.fetchHandler() — authenticated fetch for AT Protocol API calls +``` + +## Quick Start — NodeOAuthClient + +Use `@atproto/oauth-client-node` for server apps, confidential clients, and +frameworks that handle the OAuth callback on the server. ### 1. Client Metadata (confidential client) @@ -123,17 +194,24 @@ const client = new NodeOAuthClient({ ### 3. Login handler ```typescript +// Email-first OTP — pass the PDS URL, then patch the authorize URL +const emailUrl = await client.authorize('https://pds.example.com', { + prompt: 'login', +}) +emailUrl.searchParams.set('login_hint', email) +emailUrl.searchParams.set('prompt', 'login') + // No identifier — auth server shows email form -const authUrl = await client.authorize('https://pds.example.com') +const pdsUrl = await client.authorize('https://pds.example.com') // With a handle — stock PDS handle/password page -const authUrl = await client.authorize('alice.pds.example.com') +const handleUrl = await client.authorize('alice.pds.example.com') // With a DID — same behaviour as handle -const authUrl = await client.authorize('did:plc:abc123...') +const didUrl = await client.authorize('did:plc:abc123...') ``` -Redirect the user's browser to `authUrl`. +Redirect the user's browser to the returned URL for the path they chose. ### 4. Callback handler @@ -169,20 +247,23 @@ app.get('/jwks.json', (req, res) => { }) ``` -## Quick Start — Flow 1 (hand-rolled) +## Fallback — Hand-rolled PAR/DPoP -Flow 1 requires hand-rolled PAR and token exchange because the library -cannot pass a raw email as `login_hint`. See -[references/flows.md](references/flows.md) for the full walkthrough and -[references/dpop-pkce.md](references/dpop-pkce.md) for the helper -functions. +Hand-rolled PAR and token exchange are no longer the default recommendation +for email-first ePDS login. Use the Browser or Node OAuth client first and +append email `login_hint` to the returned authorize URL. Only hand-roll when +your OAuth client cannot expose that URL before redirecting, when you need a +custom OAuth behavior the library cannot represent, or when you are debugging +PAR/DPoP itself. See [references/flows.md](references/flows.md) for the full +walkthrough and [references/dpop-pkce.md](references/dpop-pkce.md) for the +helper functions. -The abbreviated version: +The abbreviated fallback version: -1. Generate DPoP key pair and PKCE verifier -2. POST to `/oauth/par` (with DPoP nonce retry) -3. Redirect browser to `/oauth/authorize?...&login_hint=` -4. Handle callback: verify state, exchange code for tokens (with DPoP nonce retry) +1. Generate DPoP key pair and PKCE verifier. +2. POST to `/oauth/par` without email `login_hint` (with DPoP nonce retry). +3. Redirect browser to `/oauth/authorize?...&login_hint=`. +4. Handle callback: verify state, exchange code for tokens (with DPoP nonce retry). ## Forcing a Fresh Sign-In (`prompt=login`) @@ -195,11 +276,11 @@ standard OIDC `prompt=login` parameter. engage session reuse by inspecting the **query string** of the `/oauth/authorize` redirect. PAR-body `prompt=login` is ignored. -If your OAuth library (e.g. `NodeOAuthClient`) only supports passing -`prompt` via the PAR body, you must also append `&prompt=login` to the -authorization URL the library returns before redirecting the user. +Even when you pass `prompt` to `authorize()` options, also append +`&prompt=login` to the authorization URL the library returns before redirecting +the user. -**Hand-rolled (Flow 1):** +**Hand-rolled fallback:** ```typescript const authUrl = @@ -208,28 +289,31 @@ const authUrl = (forceLogin ? '&prompt=login' : '') ``` -**With `NodeOAuthClient` (Flow 2):** +**With `BrowserOAuthClient` or `NodeOAuthClient`:** ```typescript const url = await client.authorize(input, { prompt: 'login' }) -// Library puts prompt in PAR; also append it to the URL query string +// The library puts prompt in PAR; also append it to the URL query string // so ePDS's session-reuse short-circuit fires. url.searchParams.set('prompt', 'login') + +// For email-first OTP, append the email to the returned authorize URL too. +url.searchParams.set('login_hint', email) ``` ## Common Pitfalls -| Pitfall | Fix | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| Consent screen on every login | Switch to `private_key_jwt` — public clients force consent unless in the PDS trusted list | -| Flash of email form (Flow 1) | Include `login_hint` on the **auth redirect URL only** (never in the PAR body) | -| `Invalid login_hint` from PAR | Remove `login_hint` from the PAR body — PDS core only accepts handles/DIDs, not emails | -| `auth_failed` immediately | Check Caddy logs — likely a DNS/upstream name mismatch | -| DPoP rejected (hand-rolled only) | Always implement the nonce retry loop (ePDS always demands a nonce) | -| Token exchange fails (hand-rolled) | Restore the DPoP key pair from the session cookie, don't generate a new one | -| `Cannot find package` in tests | Run `pnpm build` before `pnpm test` — vitest needs `dist/` | -| `NodeOAuthClient` callback 401 | Ensure `stateStore` and `sessionStore` persist across requests (not in-memory for serverless) | -| `prompt=login` ignored, chooser still shown | Append `&prompt=login` to the **authorize URL** query string — PAR body alone doesn't engage ePDS's short-circuit | +| Pitfall | Fix | +| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| Consent screen on every login | Switch server apps to `private_key_jwt`; public browser clients force consent unless in the PDS trusted list | +| Flash of email form | Use `authorize()` instead of immediate redirect helpers, then append `login_hint` on the **auth redirect URL only** | +| `Invalid login_hint` from PAR | Remove email `login_hint` from the PAR body — PDS core only accepts handles/DIDs there | +| `auth_failed` immediately | Check Caddy logs — likely a DNS/upstream name mismatch | +| DPoP rejected (hand-rolled fallback only) | Always implement the nonce retry loop (ePDS always demands a nonce) | +| Token exchange fails (hand-rolled fallback) | Restore the DPoP key pair from the session cookie, don't generate a new one | +| `Cannot find package` in tests | Run `pnpm build` before `pnpm test` — vitest needs `dist/` | +| `NodeOAuthClient` callback 401 | Ensure `stateStore` and `sessionStore` persist across requests (not in-memory for serverless) | +| `prompt=login` ignored, chooser still shown | Append `&prompt=login` to the **authorize URL** query string — PAR body alone doesn't engage ePDS's short-circuit | ## Handles @@ -248,5 +332,5 @@ Token: https:///oauth/token ## Reference Files - [Client metadata fields](references/client-metadata.md) — confidential vs public, JWKS, all fields, email branding -- [Full flow walkthrough](references/flows.md) — sequence diagrams, Flow 1 hand-rolled code, Flow 2 library code -- [PKCE and DPoP helpers](references/dpop-pkce.md) — Flow 1 only; Flow 2 should use `NodeOAuthClient` instead +- [Full flow walkthrough](references/flows.md) — sequence diagrams, BrowserOAuthClient/NodeOAuthClient examples, and hand-rolled fallback code +- [PKCE and DPoP helpers](references/dpop-pkce.md) — fallback only; prefer BrowserOAuthClient or NodeOAuthClient for normal app login diff --git a/.agents/skills/epds-login/references/dpop-pkce.md b/.agents/skills/epds-login/references/dpop-pkce.md index 58255eb3..b09c3ca8 100644 --- a/.agents/skills/epds-login/references/dpop-pkce.md +++ b/.agents/skills/epds-login/references/dpop-pkce.md @@ -1,10 +1,10 @@ # PKCE and DPoP Helper Implementations -> **Flow 2 does not need these helpers.** If your app uses -> `@atproto/oauth-client-node` (recommended for any flow that does not pass -> a raw email as `login_hint`), the library handles PKCE, DPoP, and nonce -> retry internally. These helpers are only needed for **Flow 1** (hand-rolled -> PAR/DPoP with email `login_hint`). +> **OAuth-client flows do not need these helpers.** If your app uses +> `@atproto/oauth-client-browser` or `@atproto/oauth-client-node`, the library +> handles PKCE, DPoP, and nonce retry internally. For email-first ePDS login, +> prefer appending `login_hint` to the authorize URL returned by the client. +> These helpers are only needed for the hand-rolled PAR/DPoP fallback. Copy these into your project. They have no dependencies beyond Node's built-in `node:crypto` module. @@ -140,12 +140,12 @@ function derToRaw(der: Buffer): Buffer { } ``` -## Nonce retry pattern (Flow 1 only) +## Nonce retry pattern (hand-rolled fallback only) ePDS always rejects the first DPoP proof with a `400` and a `dpop-nonce` -header. This is standard behaviour. For Flow 1 (hand-rolled), wrap every -PAR and token request in this retry loop. Flow 2 does not need this — -`NodeOAuthClient` handles nonce retry internally. +header. This is standard behaviour. For hand-rolled PAR/DPoP, wrap every +PAR and token request in this retry loop. BrowserOAuthClient and +NodeOAuthClient do not need this — they handle nonce retry internally. ```typescript async function fetchWithDpopRetry( diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index 13f4e020..6586ae9f 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -2,31 +2,73 @@ ## Which flow should I use? -| Flow | App provides | User experience | Implementation | -| ---- | ----------------------- | ---------------------------- | -------------------- | -| 1 | Email address | OTP screen immediately | Hand-rolled PAR/DPoP | -| 2 | Nothing, handle, or DID | Depends on input (see below) | `NodeOAuthClient` | +| Path | App type | App provides | Implementation | +| -------------------- | --------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Browser OAuth client | Browser-only SPA or public client | PDS URL for email-first/no-identifier login, or handle/DID | `BrowserOAuthClient.authorize()`, append email `login_hint` and `prompt=login` to the returned authorize URL, then `init()` consumes the callback | +| Node OAuth client | Server app or confidential client | PDS URL for email-first/no-identifier login, or handle/DID | `NodeOAuthClient.authorize()`, append email `login_hint` and `prompt=login` to the returned authorize URL, then `callback()` consumes the callback | +| Hand-rolled fallback | Any runtime | Email address | Implement PAR, PKCE, DPoP, nonce retry, and token exchange yourself only if the OAuth client cannot expose the authorize URL or ePDS stops honoring URL-level hints | -**Flow 1** is the only flow that requires hand-rolled PAR and DPoP code. -`@atproto/oauth-client-node`'s `authorize()` method explicitly omits -`login_hint` from its options — the library resolves handles and DIDs -itself and overrides the hint. Since Flow 1 needs to pass a raw email -as `login_hint` on the auth redirect URL (not in the PAR body), it -cannot use the library. +Use the OAuth clients first. They handle PAR, PKCE, DPoP, nonce retry, token +exchange, and session management automatically. For email-first OTP, pass the +PDS URL to `authorize()` and append the raw email as `login_hint` to the returned +`/oauth/authorize` URL. Do **not** put email `login_hint` in the PAR body. -**Flow 2** should use `NodeOAuthClient`, which handles PAR, PKCE, DPoP, -nonce retry, token exchange, and session management automatically. It -covers three input variants — all use the same code path: +Use these input variants with the same client code: -- **No identifier** — pass the PDS URL; auth server shows its own email form -- **Handle** — pass `alice.pds.example.com`; this fork shows the stock PDS handle/password page -- **DID** — pass `did:plc:abc123...`; same as handle +- **Email-first OTP** — pass the PDS URL, then append `login_hint=` and `prompt=login` to the returned URL. +- **No identifier** — pass the PDS URL; auth server shows its own email form. +- **Handle** — pass `alice.pds.example.com`; this fork shows the stock PDS handle/password page. +- **DID** — pass `did:plc:abc123...`; same as handle. -Both flows end with ePDS redirecting back to your app, and your callback receives an authorization code to exchange for tokens. +All paths end with ePDS redirecting back to your app with an authorization code. --- -## Flow 2 — Using `NodeOAuthClient` +## Browser apps — Using `BrowserOAuthClient` + +### Setup + +Use `@atproto/oauth-client-browser` for browser-only apps. See the +[SKILL.md quick start](../SKILL.md) for public browser client metadata. + +### Login handler + +```typescript +import { BrowserOAuthClient } from '@atproto/oauth-client-browser' + +const client = await BrowserOAuthClient.load({ + clientId: 'https://yourapp.example.com/client-metadata.json', +}) + +const url = await client.authorize('https://pds.example.com', { + scope: 'atproto transition:generic', + prompt: 'login', +}) +url.searchParams.set('login_hint', email) +url.searchParams.set('prompt', 'login') +window.location.assign(url.toString()) +``` + +Use `authorize()` rather than `signInRedirect()` when you need email-first OTP, +because `signInRedirect()` redirects before you can append `login_hint`. + +### Restore or consume the callback + +```typescript +const result = await client.init() +const session = result?.session +``` + +The `init()` method: + +1. Detects OAuth callback parameters in the current URL. +2. Validates state from browser storage. +3. Exchanges the authorization code for tokens with DPoP. +4. Restores any existing session when there is no callback to consume. + +--- + +## Server apps — Using `NodeOAuthClient` ### Setup @@ -36,22 +78,29 @@ construction (client metadata, keyset, stores). ### Login handler ```typescript +// Email-first OTP — pass the PDS URL, then patch the authorize URL +const emailUrl = await client.authorize('https://pds.example.com', { + prompt: 'login', +}) +emailUrl.searchParams.set('login_hint', email) +emailUrl.searchParams.set('prompt', 'login') + // No identifier — auth server shows email form -const authUrl = await client.authorize('https://pds.example.com') +const pdsUrl = await client.authorize('https://pds.example.com') // With a handle — stock PDS handle/password page -const authUrl = await client.authorize('alice.pds.example.com') +const handleUrl = await client.authorize('alice.pds.example.com') // With a DID — same behaviour as handle -const authUrl = await client.authorize('did:plc:abc123...') +const didUrl = await client.authorize('did:plc:abc123...') ``` The `authorize()` method: -1. Resolves the input (handle → DID → PDS endpoint, or uses the PDS URL directly) -2. Sends a PAR request with PKCE and DPoP (including nonce retry) -3. Stores the OAuth state in your `stateStore` -4. Returns the authorization URL to redirect the user to +1. Resolves the input (handle → DID → PDS endpoint, or uses the PDS URL directly). +2. Sends a PAR request with PKCE and DPoP (including nonce retry). +3. Stores the OAuth state in your `stateStore`. +4. Returns the authorization URL to redirect the user to. Redirect the user's browser to the returned URL. @@ -82,27 +131,30 @@ const session = await client.restore(userDid) // session.signOut() to end the session ``` -### Step-by-step (no identifier) +### Step-by-step (email-first OTP) -1. User clicks "Sign in" in your app -2. Your login handler calls `client.authorize('https://pds.example.com')` -3. Library sends PAR request, gets `request_uri`, stores state -4. Your app redirects browser to the returned auth URL -5. Auth server shows email form -6. User enters email, receives OTP, enters it -7. **New users only**: ePDS shows a handle picker -8. Auth server redirects to your `redirect_uri` with `?code=&state=&iss=` -9. Your callback calls `client.callback(params)` — library handles token exchange -10. User is logged in +1. User enters an email and clicks "Sign in" in your app. +2. Your login handler calls `client.authorize('https://pds.example.com')`. +3. Library sends PAR request, gets `request_uri`, and stores state. +4. Your app appends `login_hint=` and `prompt=login` to the returned auth URL. +5. Your app redirects browser to the patched auth URL. +6. Auth server sends OTP and shows the OTP entry screen. +7. User enters the OTP. +8. **New users only**: ePDS shows a handle picker. +9. Auth server redirects to your `redirect_uri` with `?code=&state=&iss=`. +10. Your callback calls `client.callback(params)` — library handles token exchange. +11. User is logged in. When passing a handle or DID instead of the PDS URL, the flow is identical except the user skips the ePDS email form and lands on the stock PDS handle/password page. --- -## Flow 1 — Hand-rolled (email `login_hint`) +## Fallback — Hand-rolled PAR/DPoP with email `login_hint` -Flow 1 requires hand-rolled PAR and token exchange because the library -cannot pass a raw email as `login_hint`. +Hand-rolled PAR and token exchange are only needed when your OAuth client cannot +expose the returned authorize URL before redirecting, when you need custom OAuth +behavior the library cannot represent, or when you are debugging PAR/DPoP itself. +Prefer `BrowserOAuthClient` or `NodeOAuthClient` for normal app login. ### Step-by-step @@ -274,7 +326,7 @@ export async function handleCallback(params: { code: string; state: string }) { ## Sequence diagrams -### Flow 1 — App passes email as `login_hint` +### Hand-rolled fallback — App passes email as `login_hint` ```mermaid sequenceDiagram @@ -302,13 +354,13 @@ sequenceDiagram App-->>User: Logged in ``` -### Flow 2 — No identifier (via `NodeOAuthClient`) +### OAuth client — No identifier or email-first OTP ```mermaid sequenceDiagram actor User participant App as Your App - participant Lib as NodeOAuthClient + participant Lib as OAuth client participant PDS as ePDS participant Auth as Auth Server participant Inbox as User's Inbox @@ -318,14 +370,19 @@ sequenceDiagram Lib->>PDS: POST /oauth/par (auto DPoP + PKCE) PDS-->>Lib: { request_uri } Lib-->>App: auth URL + App->>App: Optionally append login_hint=email and prompt=login App-->>User: Redirect to auth URL User->>Auth: GET /oauth/authorize - Auth-->>User: Shows email input form - - User->>Auth: Submits email - Auth->>Inbox: Sends 8-digit OTP - Auth-->>User: Shows OTP entry screen + alt login_hint=email was appended + Auth->>Inbox: Sends 8-digit OTP + Auth-->>User: Shows OTP entry screen + else no login_hint + Auth-->>User: Shows email input form + User->>Auth: Submits email + Auth->>Inbox: Sends 8-digit OTP + Auth-->>User: Shows OTP entry screen + end User->>Auth: Submits OTP code Auth-->>User: Redirect to callback URL @@ -338,7 +395,7 @@ sequenceDiagram App-->>User: Logged in ``` -### Flow 2 with handle or DID +### OAuth client with handle or DID Same as the diagram above except: