Agent Sign-In lets an AI agent sign in to your application the way a person
does — through the browser-based OAuth/OIDC authorization code flow — with
an identity its owner controls. Your app adds two config values. The agent
enrolls once. After that, the agent signs in by itself, and the owner can
revoke it with one click.

Agents here sign **into your app**. If you instead need to verify agents
that call your API directly, use [machine identity](/docs/machine-identity)
— the two products solve different problems and are not interchangeable.
For the complete picture, see [AgentSec](/docs/agentsec).

## How it works

Five things, no more:

| Thing | What it is |
|-------|------------|
| **Agent identity** | A synthetic address like `agent-support@acme.com` on a tenant domain you have verified, owned by a directory user |
| **Sign-in key** | The public half of a P-256 keypair generated in the agent's browser. The private half never leaves that browser |
| **Open client** | Your app's `client_id` is simply its `https` origin. No registration, no secret, no client row |
| **Remembered approval** | The first sign-in asks the agent (a human approving on its behalf) to trust your app; approval is remembered and slides 180 days |
| **Honest revocation** | The owner can suspend or revoke the agent, or revoke a single key, at any time — with exactly the effects documented below |

## Where everything lives

Every environment is its own OIDC issuer:

| Piece | Value |
|-------|-------|
| Issuer (`iss`) | `https://id.authdog.com/oidc/{environmentId}` |
| Discovery document | `https://id.authdog.com/oidc/{environmentId}/.well-known/openid-configuration` |
| JWKS (public keys) | `https://id.authdog.com/oidc/{environmentId}/.well-known/jwks.json` |

Find `{environmentId}` in the console under your environment's settings.
If you mapped a vanity domain onto the environment, it serves the same
paths — the issuer string is whatever the discovery document says it is.

The discovery document is the source of truth for endpoints
(`authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`,
`jwks_uri`) and for the signing algorithm
(`id_token_signing_alg_values_supported` — the environment's active key,
ES256 or RS256). Validate tokens against the JWKS by `kid` and refetch the
JWKS when you see an unknown `kid`; keys rotate.

## Your app: two config values

Point any OAuth/OIDC library at your Authdog environment:

1. `client_id` — your app's **https origin**, e.g. `https://app.example.com`. That's it. Nothing to register.
2. `redirect_uri` — any https URL **on that origin (or a subdomain)**, e.g. `https://app.example.com/callback`.

Two rules are enforced server-side:

- **PKCE with `code_challenge_method=S256` is mandatory.** Open clients have no secret; PKCE is the transaction integrity. The challenge is `base64url(SHA256(verifier))`; the verifier is a 43–128 character random string, as in RFC 7636.
- **Scopes are capped at `openid email profile`.** Anything outside that ceiling is rejected with `invalid_scope`.

Local development: `http` and `localhost` client_ids are rejected, so test
through a tunnel or a staging domain with real https.

### Start the sign-in

```
GET https://id.authdog.com/oidc/{environmentId}/authorize?
    client_id=https%3A%2F%2Fapp.example.com
    &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
    &response_type=code
    &scope=openid%20email%20profile
    &state=<random, you verify it on callback>
    &nonce=<random, echoed into the id_token>
    &code_challenge=<base64url(SHA256(code_verifier))>
    &code_challenge_method=S256
```

`state` and `nonce` are optional but recommended; any OIDC library
generates and checks both for you. On success you are redirected to your
`redirect_uri` with a single-use `code` (valid 60 seconds) and your
`state`. On failure the redirect carries `error` and `error_description`
instead.

### Exchange the code

```bash
curl -X POST "$TOKEN_ENDPOINT" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=authorization_code" \
  --data-urlencode "client_id=https://app.example.com" \
  --data-urlencode "code=$CODE" \
  --data-urlencode "redirect_uri=https://app.example.com/callback" \
  --data-urlencode "code_verifier=$VERIFIER"
```

Response:

```json
{
  "access_token": "…",
  "token_type": "Bearer",
  "expires_in": 600,
  "id_token": "…",
  "scope": "openid email profile"
}
```

There is **no refresh token** — by design. A refresh token is a standing
credential; agents re-run the authorization code flow instead, which is
zero-interaction once the approval is remembered. When the access token
expires, run `authorize` again.

Errors you can actually hit:

| `error` | Where it surfaces | Cause |
|---------|-------------------|-------|
| `invalid_request` | authorize redirect | Missing PKCE S256, non-https `client_id`, cross-site `redirect_uri` |
| `invalid_scope` | authorize redirect | Scope outside the `openid email profile` ceiling |
| `invalid_grant` | token endpoint response | Code expired (60 s), already used, `redirect_uri` mismatch, or `code_verifier` does not match the challenge |

### Validate the id_token

The `id_token` and the access token are both signed JWTs. Verify the
signature against the JWKS (`{issuer}/.well-known/jwks.json`, key picked
by the token's `kid` header), using the algorithm advertised in discovery.
Then check `iss` (exact issuer string above), `aud` (your exact
`client_id`), `exp`, and the `nonce` you sent. The access token carries
the same `iss`/`aud` plus `typ: "agent_at"` — accept it at your own API or
pass it to userinfo; it is not a credential for anyone else.

One consistency rule: `iss` is the host the sign-in ran against. If you
use a vanity domain, run discovery, authorize, and validation all against
that same host. A decoded id_token looks like:

```json
{
  "sub": "9vK2mX...",
  "actor_type": "agent",
  "email": "agent-support@acme.com",
  "email_verified": true,
  "name": "Support agent",
  "preferred_username": "agent-support_acme-com",
  "iss": "https://id.authdog.com/oidc/env_01J...",
  "aud": "https://app.example.com",
  "iat": 1791278000,
  "exp": 1791278600,
  "nonce": "2c1e4d...",
  "jti": "018f...",
  "scope": "openid email profile"
}
```

### Call userinfo

```bash
curl "$USERINFO_ENDPOINT" -H "Authorization: Bearer $ACCESS_TOKEN"
```

(`userinfo_endpoint` comes from discovery, like the others.)

The issuer re-checks the agent's status and the signing key's status on
**every** userinfo call. A suspended agent, a revoked agent, or a revoked
key fails the call immediately with `invalid_token` — and because tokens
live at most 10 minutes, every token everywhere stops working within that
window.

## The agent side

### Enroll once

The agent's owner creates the agent identity in the
[console](/docs/console/agents) and opens the generated enrollment link in
the agent's browser (the browser profile the agent actually runs in). That
page offers two storage options for the same key model:

- **Generate key in this browser** — generates an ECDSA P-256 keypair
  locally, marks the private half non-exportable and never transmitted,
  keeps it in the browser, and sends only the public half plus a key id
  (`kid`) to Authdog.
- **Generate key file (headless agents)** — for agents that sign in from
  their own runtime (no browser). The private half is written to an
  `agent-key.pem` file only you control; move it to the agent's runtime.
  Store it like a password. The key id is shown for the sign-in command.

Keys live **30 days** from activation. Enroll a new key before the old one
expires to avoid a sign-in gap. Up to **5 active keys** per agent are kept —
rotation means: enroll the new key, confirm sign-in works, revoke the old
one. The agent's subject (`sub`) never changes during rotation.

### Sign in

When your app redirects to `authorize`, the issuer opens a waiting page in
the agent's browser:

- **Headless agents** — the page shows a `RUN THIS WITH YOUR AGENT`
  command. The agent's runtime signs the page's one-time auth token with
  its key file and POSTs the proof:

  ```bash
  # Values from the waiting page (export so the node child sees them):
  export AUTH_TOKEN=<one-time token from the page>
  export AUTHDOG_KID=<key id shown at enrollment>

  # Sign the token with the P-256 key file. The issuer expects the raw
  # 64-byte r||s signature (WebCrypto / IEEE P1363 form), base64url —
  # not the DER form openssl emits by default. Node does this directly:
  SIGNATURE=$(node -e '
    const crypto = require("crypto");
    const fs = require("fs");
    const sig = crypto.sign("sha256", Buffer.from(process.env.AUTH_TOKEN), {
      key: fs.readFileSync("./agent-key.pem"),
      dsaEncoding: "ieee-p1363",
    });
    process.stdout.write(sig.toString("base64url"));
  ')

  curl -X POST "https://id.authdog.com/oidc/{environmentId}/agents/wait" \
    --data-urlencode "auth_token=$AUTH_TOKEN" \
    --data-urlencode "kid=$AUTHDOG_KID" \
    --data-urlencode "signature=$SIGNATURE"
  ```

  The page shows what your app will receive, polls, and the moment the
  proof lands the browser is sent back to your `redirect_uri` with the
  code. The transaction expires in **5 minutes** — if no proof lands in
  that window, no redirect fires and your app simply never hears back;
  start `authorize` again. The command is safe to re-run against a fresh
  page. Proving also remembers the approval for your app, so later
  headless sign-ins stay one command.

- **Browser agents** — "Sign in manually" opens the browser-key path: the
  agent proves possession of its stored key by signing a short-lived
  challenge, gets a 30-day session cookie, and is sent straight back to
  your app. The first sign-in for a given app also shows a one-time
  approval page.

Both paths are automatic on every later sign-in — the whole loop is
zero-interaction from your app's point of view.

## Tokens and claims

The `id_token` is a signed JWT with a **10-minute lifetime** (600 seconds),
signed with the environment's active key and pinned by `kid`. `aud` is your
exact `client_id` (the origin), `iss` is the environment issuer, and the
`nonce` you sent is echoed back.

| Claim | Scope | Value |
|-------|-------|-------|
| `sub` | always | The agent's 43-character opaque subject. Stable while the agent exists; **a deleted and recreated agent gets a new `sub`** |
| `actor_type` | always | The literal string `agent` |
| `email` | `email` | The agent's synthetic address, e.g. `agent-support@acme.com` |
| `email_verified` | `email` | `true` — the address is synthetic on a domain the tenant verified |
| `name` | `profile` | The agent's display name |
| `preferred_username` | `profile` | Derived from the address: local part + `_` + domain with dots as dashes (`agent-support_acme-com`) |
| `jti` | always | Unique token id |
| `scope` | always | The granted scope set, space-separated |

Claims are **present or absent, never null**: if a scope was not granted,
its claims are simply omitted from the token and the userinfo response.
There are no `owner_*` claims in this release. An empty `scope` parameter
falls back to `openid email`.

Any enrolled agent can sign in to any open client — that is what makes the
model spread. Your app decides which agents it trusts: key policy off
`actor_type`, `sub`, or the address domain (e.g. only accept
`*@your-customer.com`). Per-app allowlists arrive with registered clients
in a later release.

## Lifetimes

Every number here is pinned by the issuer's test suite.

| Value | Lifetime |
|-------|----------|
| Waiting-page auth token | 5 minutes, one proof |
| Authorization code | 60 seconds, single use |
| Access token / id_token | 10 minutes (600 seconds) |
| Sign-in challenge | 5 minutes |
| Agent session cookie | 30 days |
| Sign-in key | 30 days from activation |
| Remembered approval | 180 days, sliding (refreshed at each sign-in) |

## Revocation semantics

Revocation is honest — each action does exactly this and nothing else:

| Action | Immediate effect | Tokens already issued | Sessions your app created |
|--------|------------------|-----------------------|--------------------------|
| **Revoke a sign-in key** | That key can no longer prove sign-in; new sign-ins with it stop | userinfo fails immediately (live re-check); the token itself never outlives 10 minutes | Not affected — they belong to your app |
| **Suspend the agent** | All sign-ins blocked | userinfo fails immediately; tokens never outlive 10 minutes | Not affected |
| **Revoke the agent** | Same as suspend, and the agent is marked revoked permanently | userinfo fails immediately; tokens never outlive 10 minutes | Not affected |
| **Delete the agent** | Everything above, plus the identity is gone | userinfo fails immediately | Not affected |

Third-party app sessions created from an agent sign-in are the app's
sessions — Authdog revokes the Authdog side (sign-ins and tokens), never
your app's session records.

## Security notes

- `client_id` values that are not https origins (http, localhost, URLs with
  paths) are rejected before anything else happens.
- Redirect URIs must be https and on the client origin's host or a
  subdomain of it.
- Codes are stored hashed and are single-use; replaying one returns
  `invalid_grant`.
- The agent endpoints are rate-limited per environment and per IP alongside
  the rest of the OIDC surface.
- Approving an app is remembered **per scope set and per redirect URI**: if
  your app later requests different scopes or a different callback, the
  approval page is shown again.