Journey. As a developer, I want my users to sign in with the identity
providers they already trust — Apple, Google, or a device passkey — and I
want to discover which of those methods my app can offer, for which users,
without hard-coding anything.
The Auth Service is the platform's only front door for identity. It has no username/password database: production sign-in is federated (Apple or Google ID tokens) or passkey (WebAuthn). This guide covers the discovery call that tells your app which methods to render, a complete federated sign-in end-to-end, the passkey ceremony's shape, and the session lifecycle (refresh rotation, logout) that every sign-in returns into. Verified against the local stack (auth on :40004); the shared dev subdomain behaves identically.
Prerequisites
- The Auth Service is reachable (local stack
:40004, or the shared dev
subdomain).
- Your tenant key pair in
INAYA_API_KEY/INAYA_APP_KEYand your tenant id
in TENANT_ID. Sign-in endpoints sit in the vendor-auth tier: the key pair is required, but no user JWT exists yet — that is what sign-in issues.
curlpluspython3for JSON assertions. Snippets also show the response
shapes your client code should expect.
Concepts (only what this journey needs)
- Three auth tiers, one router. Public routes (
/v3/health,
/v3/auth/login_methods, docs) need no credentials. Vendor-auth routes (federated login, passkey begin/finish, refresh, and the lower-environment QA login) need the tenant key pair. JWT routes (/v3/auth/logout) need the key pair plus a user JWT — they are only callable once sign-in has succeeded.
- Discovery before rendering. Call
POST /v3/auth/login_methodswith the
user's locale. It returns all_methods (the full catalog the platform understands) and regional_methods (what this locale may actually offer — today every region resolves to Apple sign-in, and a locale without a region suffix returns an empty list). Render the intersection with what your app supports.
- Federated login exchanges a provider token for a platform session.
POST /v3/auth/federated/login takes the provider (Apple or Google) and the provider-issued id_token; the service verifies it with the provider, finds or creates the user, and returns {refresh_token, token, user} — the same response shape every sign-in path returns. A fresh user is born with the default Subscriber role (see G-30 for the full user lifecycle).
- The local environment has a development bypass. When the service runs
with INAYA_LABS_ENVIRONMENT=local, the id_token field is instead read as a user id: an existing user is signed in directly, an unknown id mints a fresh user. This lets you exercise the entire federated flow without real Apple/Google tokens. It never activates outside local.
- Passkeys use the WebAuthn two-step ceremony.
register/beginissues a
creation challenge, the device authenticator signs it, register/finish stores the credential; login/begin + login/finish mirror that for assertions. Your client passes options to navigator.credentials.create() / .get() and returns the signed credential to the matching finish endpoint.
- Sessions rotate and can be revoked. The
refresh_tokenrotates on every
use — reuse of an old one is treated as theft and revokes the session (403). PUT /v3/auth/logout revokes the caller's JWT. Full token lifecycle detail lives in G-01.
Step 1 — Discover the methods your app may offer
login_methods is public — no key pair, no JWT — so your sign-in screen can call it before anything exists:
echo "-- locale with a region suffix (en-US) --"
curl -s -X POST "${AUTH_URL}/v3/auth/login_methods" \
-H "Content-Type: application/json" \
-d '{"locale":"en-US"}'
echo
echo "-- locale without a region (en) --"
curl -s -X POST "${AUTH_URL}/v3/auth/login_methods" \
-H "Content-Type: application/json" \
-d '{"locale":"en"}'
echo
echo "-- missing locale -> 422 --"
curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST \
"${AUTH_URL}/v3/auth/login_methods" \
-H "Content-Type: application/json" -d '{}'Expected response — 200 with {"all_methods":["FederatedApple","FederatedGoogle","Passkey"],"regional_methods":[...]}. regional_methods is ["FederatedApple"] for any region-suffixed locale and [] when the locale carries no region. A body without locale is rejected 422 at deserialization.
Step 2 — Federated sign-in, end to end
In local the id_token field carries a user id, so the full flow is: create a user (here via the QA shortcut — see G-01), then federate to that id. The response must come back with the same user and a fresh session:
KEYS=(-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
# 0. Create a user to federate to (QA shortcut — lower environments only;
# production supplies a real Apple/Google id_token instead).
TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g31-demo-user\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
export TOKEN
USER_ID=$(python3 - <<'EOF'
import base64, json, os
segment = os.environ["TOKEN"].split(".")[1]
segment += "=" * (-len(segment) % 4)
print(json.loads(base64.urlsafe_b64decode(segment))["sub"])
EOF
)
# 1. Federated sign-in to that user id (local bypass stands in for a real
# provider token; provider must still be a valid enum value).
curl -s -X POST "${AUTH_URL}/v3/auth/federated/login" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"id_token\": \"${USER_ID}\", \"locale\": \"en-US\", \"provider\": \"Google\", \"tenant_id\": \"${TENANT_ID}\"}" \
> /tmp/g31-fed.json
python3 - <<EOF
import json
r = json.load(open("/tmp/g31-fed.json"))
print("keys:", sorted(r.keys()))
print("same user:", r["user"]["id"] == "${USER_ID}")
print("display_name:", repr(r["user"]["display_name"]), "| roles:", r["user"]["roles"])
print("access token present:", bool(r["token"]), "| refresh token present:", bool(r["refresh_token"]))
EOFExpected response — 200 with token, refresh_token, and a user whose id equals the id you federated to. Two things worth knowing about the bypass's find-or-create: passing an unknown id mints a fresh user each call (the new record's id is random, not the id you passed, so the next call misses again — don't loop it), and the minted user has an empty display_name until your app sets one via G-30. In production the same call with a real provider id_token finds or creates the user tied to that provider identity, stably.
Step 3 — The passkey ceremony
Passkeys are the platform's passwordless path: registration binds a device credential to a user; login asserts it. Both are begin/finish pairs — your client drives the authenticator between the two calls.
Login begin (userless / discoverable-credential mode — no email) works today and returns the challenge your browser feeds to navigator.credentials.get():
curl -s -X POST "${AUTH_URL}/v3/auth/pass_key/login/begin" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\"}" \
| python3 -c 'import json,sys; o=json.load(sys.stdin); print("challenge_id present:", bool(o["challenge_id"])); print("publicKey fields:", sorted(o["options"]["publicKey"].keys()))'Expected response — 200 with challenge_id and options.publicKey (a standard WebAuthn PublicKeyCredentialRequestOptions: challenge, rpId, timeout, userVerification, allowCredentials). Your client posts the signed assertion to pass_key/login/finish with the challenge_id; a valid assertion returns the same {refresh_token, token, user} shape as federated login. A wrong or stale assertion returns 401.
Registration begins with pass_key/register/begin: the service finds or creates the account for the email, issues a creation challenge, and your browser feeds options to navigator.credentials.create(). The signed attestation goes to register/finish, which stores the credential and returns {"registered": true}:
curl -s -X POST "${AUTH_URL}/v3/auth/pass_key/register/begin" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d "{\"email\": \"g31-passkey@example.com\", \"platform\": \"macbook-pro-m4\", \"device_id\": \"g31-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\"}" \
| python3 -c 'import json,sys; o=json.load(sys.stdin); print("challenge_id present:", bool(o["challenge_id"])); print("publicKey fields:", sorted(o["options"]["publicKey"].keys()))'Expected response — 200 with challenge_id and options.publicKey (a standard WebAuthn PublicKeyCredentialCreationOptions: challenge, rp, user, pubKeyCredParams, authenticatorSelection, attestation, extensions, timeout). The platform user id is mapped to a stable UUIDv5 handle under the hood, so the same account always presents the same RP user handle. register/finish posts the signed credential with the challenge_id and returns 200 {"registered": true}; a bad attestation returns 401 with a JSON error body, and a validation failure returns 400 with one too.
Two behaviors to design around:
login/beginscoped to an email fails400(`{"error":"No registered
passkeys found for this email address"}`) until that email has at least one registered credential — standard WebAuthn semantics. The userless (discoverable-credential) begin above always works.
- *A passkey-provisioned account is* found-or-created by email.** The email
supplied to register/begin is persisted on the freshly provisioned account as an unverified lookup key (the WebAuthn credential itself is the proof of control; the email is only used to resolve the account on later calls). Repeating register/begin for the same address therefore reuses the same account and presents the same RP user handle — the account is not re-provisioned per call. Email-scoped login/begin still fails 400 until at least one credential's finish has landed for that account.
Step 4 — The session that sign-in returns
Every successful sign-in returns the same session triple. Rotate the refresh token to extend the session; logout to end it:
KEYS=(-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
RESP=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g31-demo-user\"}")
TOKEN=$(printf '%s' "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
REFRESH=$(printf '%s' "$RESP" | python3 -c 'import json,sys; print(json.load(sys.stdin)["refresh_token"])')
# 1. Refresh rotates: a new access token AND a new refresh token are issued.
curl -s -X POST "${AUTH_URL}/v3/auth/refresh" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"refresh_token\": \"${REFRESH}\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\"}" \
> /tmp/g31-refresh.json
python3 -c 'import json; r=json.load(open("/tmp/g31-refresh.json")); print("rotated:", bool(r["token"]) and bool(r["refresh_token"]))'
# 2. Reusing the OLD refresh token is treated as theft -> 403, session revoked.
curl -s -o /dev/null -w "reuse -> HTTP %{http_code}\n" -X POST \
"${AUTH_URL}/v3/auth/refresh" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"refresh_token\": \"${REFRESH}\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\"}"
# 3. Logout revokes the caller's JWT (JWT + key pair required).
curl -s -w "\nlogout -> HTTP %{http_code}\n" -X PUT \
"${AUTH_URL}/v3/auth/logout" "${KEYS[@]}" \
-H "Authorization: Bearer ${TOKEN}"Expected response — refresh returns 200 with a brand-new token pair; reusing the superseded refresh token returns 403 {"error":"parent_token_expired"}; logout returns 200 "Logged out successfully". Calling logout without a JWT returns 401. (Token expiry, retirement, and the revocation store are G-01 territory.)
Full working example
A complete discover → federate → refresh → logout cycle (federated_signin.sh), self-contained via the local bypass:
#!/usr/bin/env bash
set -euo pipefail
KEYS=(-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
# 1. Discover methods for the sign-in screen
curl -sf -X POST "${AUTH_URL}/v3/auth/login_methods" \
-H "Content-Type: application/json" -d '{"locale":"en-US"}' \
| grep -q '"FederatedGoogle"' && echo "methods discovered"
# 2. Federated sign-in end-to-end (local bypass)
TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g31-demo-user\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
export TOKEN
USER_ID=$(python3 -c 'import base64,json,os
segment = os.environ["TOKEN"].split(".")[1]
segment += "=" * (-len(segment) % 4)
print(json.loads(base64.urlsafe_b64decode(segment))["sub"])')
export USER_ID
curl -sf -X POST "${AUTH_URL}/v3/auth/federated/login" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"id_token\": \"${USER_ID}\", \"locale\": \"en-US\", \"provider\": \"Apple\", \"tenant_id\": \"${TENANT_ID}\"}" \
> /tmp/g31-fed.json
python3 -c 'import json; r=json.load(open("/tmp/g31-fed.json")); assert r["user"]["id"] == "'"${USER_ID}"'", "federated user mismatch"; print("federated sign-in ok")'
# 3. Refresh the session
REFRESH=$(python3 -c 'import json; print(json.load(open("/tmp/g31-fed.json"))["refresh_token"])')
curl -sf -X POST "${AUTH_URL}/v3/auth/refresh" "${KEYS[@]}" \
-H "Content-Type: application/json" \
-d "{\"device_id\": \"g31-demo\", \"refresh_token\": \"${REFRESH}\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\"}" \
> /dev/null && echo "session refreshed"
# 4. Logout
curl -sf -X PUT "${AUTH_URL}/v3/auth/logout" "${KEYS[@]}" \
-H "Authorization: Bearer ${TOKEN}" > /dev/null && echo "logged out"(AUTH_URL is the Auth Service base URL — http://127.0.0.1:40004 on the local stack; TENANT_ID is your tenant's id.)
Try it live
Open the Playground → Users & Profile Matching — the Auth console lists the federated login methods and drives sign-in/sign-out against the same live Auth Service.
Copy agent prompt
You are working with the Inaya Labs Auth Service at AUTH_URL; the tenant key
pair is in INAYA_API_KEY / INAYA_APP_KEY and TENANT_ID. The environment is
local, so POST /v3/auth/qa/login {"device_id","locale","tenant_id",
"username"} issues a session for any username (lower environments only). Steps:
(1) POST /v3/auth/login_methods {"locale":"en-US"} (no auth needed) and report
all_methods vs regional_methods. (2) Obtain a user id via the QA login, then
complete POST /v3/auth/federated/login with that id as id_token, provider
"Google"; expect 200 with {refresh_token, token, user} where user.id matches.
(3) Start POST /v3/auth/pass_key/login/begin without an email and report the
publicKey options fields. (4) Rotate the session via POST /v3/auth/refresh
{"device_id","refresh_token","locale","tenant_id"} and confirm a new pair;
then reuse the old refresh token and report the 403. (5) PUT
/v3/auth/logout with the access token and expect 200; report the 401 without
it. Never log JWTs or refresh tokens; report status codes per step.API reference
Next journeys
- Finding matches between profiles (G-32) — turn the users you just signed in into matchable profiles.
- Managing users & profiles (G-30) — flesh out the user record every sign-in creates.
- Authentication & sessions (G-01) — the full token lifecycle: expiry, retirement, revocation store.