OAuth 2.0 Authorization Code + PKCE
logi enforces PKCE (RFC 7636) S256 on the OAuth 2.0 Authorization Code Grant. The Implicit Flow and Password Grant are not supported. For the Device Code Flow, see the dedicated Device Flow page.
Scope of this document
This page describes the flow from the perspective of a confidential client (one that holds a client_secret). For a public client (PKCE-only, mobile/SPA), only the client_secret steps are omitted. See the Public Clients guide.
Sequence diagram
sequenceDiagram
autonumber
participant C as RP app
participant L as logi
participant U as User (browser/app)
C->>C: Generate verifier (random 32B) · challenge = SHA256(verifier) b64url
C->>L: GET /oauth/authorize?client_id&redirect_uri&state&code_challenge&scope
L->>L: Validate redirect_uri against whitelist
L-->>U: Show login / consent screen
U->>L: Credentials + OTP/Passkey if required
U->>L: Click "Allow"
L-->>C: 302 redirect_uri?code=<code>&state=<state>
C->>C: Verify state matches
C->>L: POST /oauth/token (client_id+secret · code · code_verifier · redirect_uri)
L->>L: client_secret bcrypt · code_challenge vs SHA256(verifier) · consume code once
L-->>C: { access_token(JWT), refresh_token, expires_in, scope, id_token? }
C->>L: GET /oauth/userinfo (Authorization: Bearer JWT)
L-->>C: { sub, email?, identity_verified_level, ... }Step-up authentication and Passkey UV
Before a user enters a sensitive operation (for example, disabling 2FA or changing a password), logi requires step-up authentication. A session that has passed User Verification (UV — biometric/PIN) via a Passkey is treated as having cleared step-up without entering an additional OTP code. Because the WebAuthn UV flag is equivalent to AAL2, no separate OTP or backup code entry is enforced (within the PASSKEY_UV_MAX_AGE = 15 minutes window). For internal details, see the Security::StepUpVerifier service.
1. Authorization request (browser)
GET /oauth/authorize
?client_id=logi_a1b2...
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback
&response_type=code
&scope=profile+email
&state=<32-byte_random>
&code_challenge=<BASE64URL(SHA256(verifier))>
&code_challenge_method=S256
&nonce=<optional, OIDC>Required parameters:
client_id,redirect_uri,response_type=code,scope,state,code_challenge,code_challenge_method=S256
Optional:
nonce— echoed into the id_token when the openid scope is usedprovider— the login method key(s) to surface on the login screen (comma-separated, e.g.googleorapple,google). This controls visibility only and is not a security control. Invalid values are ignored. For the full behavior and precedence, see Login method restriction.prompt— OIDC §3.1.2.1. Supported values arelogin(terminates the current session and forces re-authentication) andnone(silent auth — no UI is ever shown; if the user is not logged in,error=login_requiredis reflected to the callback, and if additional consent is needed,error=consent_required). If the space-separated list contains both,logintakes precedence overnone; unsupported values (consent,select_account, etc.) are silently ignored per the OIDC recommendation.resource— an RFC 8707 Resource Indicator. It must be an absolute http(s) URI without a fragment and a registered resource server; a malformed, unregistered, or ambiguously-owned value reflectserror=invalid_targetto the callback. Consent is bound per resource — a new resource, or a scope escalation on the same resource, re-prompts for consent.ui_locales— an OIDC UI language hint. The firstko/enmatch in the space-separated preference list is applied (the supported list isui_locales_supportedin discovery). It is a cookie-based, UI-only hint: it never changes the user's account language setting and has no effect on the OAuth semantics of the request.
Error handling policy
| Error location | Response |
|---|---|
client_id not found / redirect_uri mismatch | HTML/JSON 400 (no callback — prevents open redirect) |
| Missing PKCE, invalid scope, wrong response_type | 302 redirect_uri?error=invalid_request&state=... |
| User clicked "Deny" | 302 redirect_uri?error=access_denied&state=... |
2. Token exchange (backend → backend)
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret) # or in the body
grant_type=authorization_code
&code=<received code>
&redirect_uri=<same as step 1>
&code_verifier=<the verifier you generated>Validation order (any failure returns 400 invalid_grant immediately):
- client_secret matches (bcrypt)
- code exists and
used_atis null expires_at > now(10 minutes)redirect_urimatches the stored snapshotBASE64URL(SHA256(verifier)) == code_challenge
Success response:
{
"access_token": "<JWT RS256>",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "<opaque 32B urlsafe>",
"scope": "profile email",
"id_token": "<JWT — only when the openid scope is requested>"
}3. Refresh Token Rotation
POST /oauth/token
grant_type=refresh_token
&refresh_token=<refresh_token from the previous response>
&client_id=...&client_secret=...Behavior
- Look up the DB record by the hash of the presented refresh token (RT).
revoked_atis set → revoke the entire chain +400 invalid_grant(reuse attack detected).refresh_expires_athas passed → revoke only that record + 400.- Valid → revoke the existing record and issue a new one (the
refreshed_from_idchain).
The response shape is identical to authorization_code — clients can use the same parsing path.
4. UserInfo
GET /oauth/userinfo
Authorization: Bearer <access_token JWT>The response is scope-based:
| scope | Included fields |
|---|---|
profile, email | sub, email, email_verified, identity_verified_level |
openid (in id_token) | sub (required), nonce (echoed when requested) |
Revocation
POST /oauth/revoke
token=<access_token or refresh_token>
&token_type_hint=access_token|refresh_token
&client_id=...&client_secret=...- An OAuth client can revoke only its own tokens.
- Both access tokens and refresh tokens are accepted.
- A token that is already revoked or does not exist also returns 200 OK (RFC 7009).
- Revoking a refresh token also revokes the records in its chain.
Introspection
POST /oauth/introspect
token=<access_token or refresh_token>
&token_type_hint=access_token|refresh_token
&client_id=...&client_secret=...Example response:
{
"active": true,
"scope": "profile email",
"client_id": "logi_...",
"token_type": "access_token",
"exp": 1760000000,
"iat": 1760000000,
"sub": "123",
"aud": "logi_...",
"iss": "https://api.1pass.dev",
"jti": "..."
}- If the token belongs to another client, or is expired or revoked, the response is
{ "active": false }. - An access token is looked up after JWT signature and expiry verification.
- A refresh token is looked up by digest.
QR login option
The logi /oauth/authorize page surfaces a "📱 Log in with QR" button to users. When the user chooses the QR option, they approve in the logi mobile app and the browser automatically navigates to redirect_uri?code=&state= — a response that is 100% identical to the standard Authorization Code flow.
There is nothing additional for the RP to do. For the full security model, sequence diagram, and error handling, see the QR login guide.
References
- RFC 6749 §4.1 — Authorization Code Grant
- RFC 7636 — PKCE (S256 required)
- RFC 9068 — Access Token JWT
- OpenID Connect Core 1.0 §3.1