Home Playground Docs hub

Guide G-31 · Users & Profile Matching

Federated sign-in for your app

Verified against local · 2026-09-24 · auth-service 3.0.1 (workspace HEAD; user-manager-lib create_user fix + passkey UUIDv5 handle fix)
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

subdomain).

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.

shapes your client code should expect.

Concepts (only what this journey needs)

/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.

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.

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).

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.

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.

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:

bash
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:

bash
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"]))
EOF

Expected 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():

bash
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}:

bash
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:

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.

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:

bash
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:

bash
#!/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

Agent prompt — copy and hand to your agent
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