Skip to content

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

mermaid
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 used
  • provider — the login method key(s) to surface on the login screen (comma-separated, e.g. google or apple,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 are login (terminates the current session and forces re-authentication) and none (silent auth — no UI is ever shown; if the user is not logged in, error=login_required is reflected to the callback, and if additional consent is needed, error=consent_required). If the space-separated list contains both, login takes precedence over none; 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 reflects error=invalid_target to 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 first ko/en match in the space-separated preference list is applied (the supported list is ui_locales_supported in 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 locationResponse
client_id not found / redirect_uri mismatchHTML/JSON 400 (no callback — prevents open redirect)
Missing PKCE, invalid scope, wrong response_type302 redirect_uri?error=invalid_request&state=...
User clicked "Deny"302 redirect_uri?error=access_denied&state=...

2. Token exchange (backend → backend)

http
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):

  1. client_secret matches (bcrypt)
  2. code exists and used_at is null
  3. expires_at > now (10 minutes)
  4. redirect_uri matches the stored snapshot
  5. BASE64URL(SHA256(verifier)) == code_challenge

Success response:

json
{
  "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

http
POST /oauth/token
grant_type=refresh_token
&refresh_token=<refresh_token from the previous response>
&client_id=...&client_secret=...

Behavior

  1. Look up the DB record by the hash of the presented refresh token (RT).
  2. revoked_at is set → revoke the entire chain + 400 invalid_grant (reuse attack detected).
  3. refresh_expires_at has passed → revoke only that record + 400.
  4. Valid → revoke the existing record and issue a new one (the refreshed_from_id chain).

The response shape is identical to authorization_code — clients can use the same parsing path.

4. UserInfo

http
GET /oauth/userinfo
Authorization: Bearer <access_token JWT>

The response is scope-based:

scopeIncluded fields
profile, emailsub, email, email_verified, identity_verified_level
openid (in id_token)sub (required), nonce (echoed when requested)

Revocation

http
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

http
POST /oauth/introspect
token=<access_token or refresh_token>
&token_type_hint=access_token|refresh_token
&client_id=...&client_secret=...

Example response:

json
{
  "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

Identity가 제품의 신뢰를 만듭니다.