Native apps through edge — Versola Docs
VersolaVersola/docs
Pre-1.00.7.1versola.kzGitHub

Native apps through edge

How a mobile or desktop app signs in through the edge native endpoints with PAR and a device-held DPoP key, what the client must look like, and what each call returns.

A native app cannot keep a client secret, and the fapi2 security profile refuses public clients. edge solves this by splitting the client role. edge authenticates to auth as the client, with a certificate on the mutual-TLS listener. The device holds only a DPoP key, and that key is the sender constraint on the tokens. This page describes the four endpoints the app calls. You need edge configured with a native block (Edge configuration) and a native client (How to build a native mobile app flow).

The sequence

sequenceDiagram
    autonumber
    participant M as Mobile app
    participant E as edge
    participant A as auth
    participant U as Upstream

    M->>E: POST /native/start/{clientId} + DPoP proof (device key)
    E->>A: POST /par as the client (PKCE, state, dpop_jkt)
    E-->>M: request_uri, sealed blob

    note over M,A: System browser opens auth /authorize, user signs in

    A-->>M: Redirect to the App Link with code and state
    M->>E: POST /native/complete/{clientId} + blob + DPoP proof
    E->>A: POST /token (code + PKCE verifier from blob)
    A-->>E: DPoP-bound access_token + refresh_token
    E-->>M: Token response relayed unchanged
    M->>M: Store tokens and device key (Keychain / Keystore)

    note over M,E: Session active — app sends DPoP-bound calls

    M->>E: GET /resources/api/v1/data + Authorization: DPoP + DPoP proof
    E->>E: Check revocation, token binding, permissions and CEL rules
    E->>U: Forward + inject Authorization header
    U-->>E: 200 OK
    E-->>M: 200 OK

    note over M,E: Refresh

    M->>E: POST /native/token/{clientId} (refresh_token) + DPoP proof
    E->>A: POST /token as the client
    A-->>E: new tokens
    E-->>M: new tokens
StepWhoCall
1. StartApp to edgePOST /native/start/{clientId}
2. Sign inSystem browser to authGET /authorize?client_id=...&request_uri=...
3. Returnauth to the appRedirect to the App Link with code, state and iss
4. CompleteApp to edgePOST /native/complete/{clientId}
5. UseApp to edge/resources/{resourceId}/{path} with Authorization: DPoP
6. RefreshApp to edgePOST /native/token/{clientId}
7. Sign outApp to edgePOST /native/revoke/{clientId}

edge keeps no record of a login in progress. What it would store travels as a sealed blob (AES-256-GCM, key native.blob-key) that holds the client ID, the PKCE verifier, state, the thumbprint of the device key, the redirect URI and an expiry. Tokens never rest at edge: auth remains the only token store.

What the device holds

  • A DPoP key pair, created once and kept in the platform keystore (Keychain, Android Keystore). Make it non-exportable. The algorithm must be one the server accepts: ES256 and PS256 are in the default metadata, and the client can narrow the set with dpopSigningAlgs. Prefer ES256.
  • Tokens: the access token (token_type is DPoP) and the refresh token. The access token carries cnf.jkt, the thumbprint of the device key, and no certificate thumbprint. Without the key it is useless to a thief.
  • The blob only between step 1 and step 4.

Every call to /native/* carries one DPoP header: a JWT signed by the device key with htm (POST), htu, iat and a unique jti. The htu differs per call:

Callhtu
/native/start/{clientId}The URL of that endpoint on edge, built from edge-url
/native/complete/{clientId}, /native/token/{clientId}token_endpoint from the start response
/native/revoke/{clientId}revocation_endpoint from the start response

The last two are auth endpoints on its mutual-TLS address, and edge forwards the proof to them byte for byte. The proof for start is addressed to edge itself, is never forwarded and can be used once.

When a nonce is required, the answer is 401 with WWW-Authenticate: DPoP error="use_dpop_nonce" and a DPoP-Nonce header. Build a new proof that includes the nonce and retry once. A nonce for a proof addressed to auth comes from auth, and edge relays it unchanged.

Client requirements

edge serves a client on /native/* only if all of these hold. Otherwise it answers 404, and a missing client and a misconfigured one look the same.

RequirementWhy
applicationType is nativeMarks an app fronted for the device
authMethod is tls_client_auth, with an edge certificate (Client certificates)edge authenticates as the client on auth’s mutual-TLS listener
dpopBoundAccessTokens is trueThe device key binds the tokens
certificateBoundAccessTokens is falseedge’s certificate is shared by every installation, so it cannot identify one device
requirePushedAuthorizationRequests is trueedge pushes the request to /par, so parameters never travel through the browser
Redirect URIs are https App LinksA custom scheme can be claimed by any app. A fapi2 tenant refuses them
The client’s tenant is assigned to this edgecentral syncs only the clients of the tenant’s edge

PKCE with S256 is always on. Signed request objects do not apply, because a shipped binary has no private key of its own. The console wizard Mobile or desktop app sets all of this for you.

Calls

All four calls take application/x-www-form-urlencoded bodies and answer with Cache-Control: no-store.

Start

curl -X POST https://edge.example.com/native/start/acme-mobile \
  -H "DPoP: <proof for start>" \
  -d "scope=openid offline_access" \
  -d "redirect_uri=https://app.example.com/callback"

Accepted body parameters: redirect_uri (optional when the client registered exactly one), scope, nonce, acr_values, prompt, login_hint, ui_locales, max_age, claims, resource (may repeat) and authorization_details. Anything else is ignored, and a repeated parameter other than resource is invalid_request. edge adds response_type=code, the PKCE challenge, state and dpop_jkt, and pushes the request to auth.

{
  "client_id": "acme-mobile",
  "request_uri": "urn:ietf:params:oauth:request_uri:...",
  "expires_in": 600,
  "authorization_endpoint": "https://id.example.com/authorize",
  "state": "...",
  "blob": "...",
  "token_endpoint": "https://id.example.com:8083/token",
  "revocation_endpoint": "https://id.example.com:8083/revoke"
}

expires_in is the shorter of the lifetime auth gave the request_uri and native.blob-ttl (default 600 s). Open the system browser on authorization_endpoint with client_id and request_uri as query parameters. Do not use an embedded web view.

Complete

When the browser returns to your App Link, read code, state and iss from it.

curl -X POST https://edge.example.com/native/complete/acme-mobile \
  -H "DPoP: <proof for token_endpoint>" \
  -d "code=<code>" -d "state=<state>" -d "iss=<iss>" -d "blob=<blob>"

edge opens the blob and checks that it is its own, for this client and not expired, that state matches, that iss is the authorization server the request was pushed to (RFC 9207), and that the proof is signed by the same key that started the flow. It then redeems the code at auth with the PKCE verifier from the blob and relays auth’s token response unchanged. An Idempotency-Key header is forwarded to auth.

Call the API

Use the access token with Authorization: DPoP <access token> and a proof for the edge URL. The route and policy rules are in Endpoint policies.

Refresh

curl -X POST https://edge.example.com/native/token/acme-mobile \
  -H "DPoP: <proof for token_endpoint>" \
  -H "Idempotency-Key: <unique per rotation>" \
  -d "refresh_token=<refresh token>"

Only the refresh_token grant is served. grant_type is optional, and any other value is unsupported_grant_type. scope and resource are forwarded. The Idempotency-Key makes a rotation safely retryable after a lost response.

Revoke

curl -X POST https://edge.example.com/native/revoke/acme-mobile \
  -H "DPoP: <proof for revocation_endpoint>" \
  -d "token=<refresh token>" -d "token_type_hint=refresh_token"

This is RFC 7009 revocation, relayed from auth. A revoked refresh token answers invalid_grant on the next refresh.

Errors

StatusBodyWhen
400{"error": "invalid_request", "error_description": "..."}Missing or repeated parameter, a redirect_uri that is not registered, body not form-encoded
400invalid_dpop_proofNo proof, more than one, a failed check, or a start proof already used
400invalid_grantThe blob is not from this edge, is for another client or has expired. state or iss does not match. The proof’s key differs from the one that started the flow
400unsupported_grant_type/native/token with a grant other than refresh_token
401none, WWW-Authenticate: DPoP error="use_dpop_nonce", DPoP-NonceA nonce is needed. Retry once
404noneUnknown client, a client that does not meet the requirements, or no native block

The error object follows RFC 6749 section 5.2. Refusals from auth, such as a bad redirect_uri at /par or an invalid_grant for a used code, are relayed as auth sent them.

Configuration

The native block and its keys are on Edge configuration. It requires a dpop block, or edge does not start. On the auth side you need a mutual-tls listener whose trusted-certificates file contains the CA that signs the certificate edge presents.

Limits

  • /native/token serves only refresh. Other grants are not available through edge.
  • edge does not store tokens, so it cannot list or revoke a device’s tokens for you. Use the refresh-token and session views of a user in the console, or auth’s revocation.
  • A fapi2 tenant cannot use custom-scheme redirects. Plan for App Links (Android) or Universal Links (iOS) and host the association file on your domain.

Next steps