# Sign in with Pidgeon

> Sign in with Pidgeon: an AI agent signs in to a website with its own address over OpenID Connect, and the site learns who it is and who it works for.

An app can let your agent sign in with its Pidgeon address, the way people
sign in with Google. The app asks for the address, and Pidgeon vouches for it: the
app learns that it really is `billing-agent@your-domain.com`, that it is an
agent, and which organisation it works for. It gets no access to the inbox.

It is OpenID Connect, so the app needs nothing from us beyond a standard
library. The one difference is where the issuer comes from: the address.

## For the agent: approve the sign-in

The app gives your agent a link. Your agent sends that link to Pidgeon with its
own API key, and gets back the app’s callback:

```bash
curl https://pidgeon.ai/api/v1/sign-ins \
-H "Authorization: Bearer $PIDGEON_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "'"$AUTHORIZATION_URL"'"}'
```

```ts
import { createClient, signInsApprove } from '@pidgeon-ai/sdk';

const client = createClient({
baseUrl: 'https://pidgeon.ai/api/v1',
headers: { authorization: `Bearer ${process.env.PIDGEON_API_KEY}` },
});

const { data } = await signInsApprove({ client, body: { url: authorizationUrl } });
// data.data.redirect_url: open it yourself, in the session that started the sign-in.
```

```python
import os

from pidgeon import AuthenticatedClient
from pidgeon.api.sign_ins import sign_ins_approve
from pidgeon.models import SignInsApproveBody

client = AuthenticatedClient(base_url="https://pidgeon.ai/api/v1", token=os.environ["PIDGEON_API_KEY"])

answer = sign_ins_approve.sync(client=client, body=SignInsApproveBody(url=authorization_url)).data
redirect_url = answer.redirect_url
```

The answer is `{ redirect_url, address, app, expires_at }`. Open `redirect_url`
yourself, within a minute, in the same session that started the sign-in, and
the app signs you in.

- **The key must be able to send as the address.** Signing in as an address is
  speaking as it, so a key that can only read is refused, and a key given one
  address cannot sign in as another.
- **If the link does not name an address**, say which in `address`.
- **Approve only a sign-in your agent started.** `app` says which app the code
  is for (`name`, `host`, and `verified`). Never hand `redirect_url` to anyone:
  whoever opens it is the one signed in.
- **Your key goes to Pidgeon and nowhere else.** Never follow the app’s link
  with the key attached.

A person signs in the same way from a browser: the link opens a page on Pidgeon
where they choose the address.

## For the app: add the button

1. Ask for the address, or add one issuer for every agent: Pidgeon's,
   `https://post.pidgeon.email`. It signs in any address Pidgeon hosts, on
   `pidgeon.email` or on a company's own verified domain. Then skip step 2.
2. For an address on a domain that runs its own issuer, find it: fetch
   `https://post.<domain>/.well-known/post.json` and read `identity.issuer`.
3. From there it is OpenID Connect: discovery at the issuer, the authorization
   code flow with PKCE, and the ID token checked against the issuer’s keys.
   Register with dynamic client registration, or use a Client ID Metadata
   Document URL as your `client_id`. Pass the address as `login_hint`.
4. Check that `email` is the address you asked about, then key the account on
   `sub`. **Never key it on `email`.** An address can be deleted and later
   created again by somebody else; `sub` is never given to anyone else, so the
   new holder arrives as a stranger rather than in the old one’s account.

The ID token is signed with `EdDSA` (Ed25519), the same key that signs the
domain’s mail. openid-client, jose, Auth.js and Authlib verify it. At
Pidgeon’s issuer, a client that registers `"id_token_signed_response_alg":
"ES256"` gets ES256 instead, for an identity provider that verifies no EdDSA.

| Claim          | What it says                                                         |
| -------------- | -------------------------------------------------------------------- |
| `sub`          | A stable id for the address, never reused. Key accounts on this      |
| `email`        | The address, with `email_verified: true`                             |
| `actor_type`   | `agent`, `person` or `service`                                       |
| `on_behalf_of` | The name of the workspace the address belongs to, when it has one    |
| `owner_name`   | With the `owner` scope: the person who answers for the agent         |
| `owner_email`  | With the `owner` scope: their email, when an owner chose to share it |

## In your own code

Without an identity provider, the site's half is a library in both languages:
`@pidgeon-ai/sign-in` for Next.js and Express, and `pidgeon.sign_in` in the
Python SDK for Flask, FastAPI or Django. It adds the routes a site needs: an
agent's sign-in with no person present (its ID token, in a request signed with
its own key), a person's sign-in through the button, and a session for both.

```ts
import { pidgeonSignIn } from '@pidgeon-ai/sign-in';
import { routeHandlers } from '@pidgeon-ai/sign-in/next';

export const pidgeon = pidgeonSignIn({
clientId: process.env.PIDGEON_CLIENT_ID!,
url: 'https://shop.example',
secret: process.env.SESSION_SECRET!,
});

// app/api/auth/pidgeon/[action]/route.ts
export const { GET, POST } = routeHandlers(pidgeon);
```

```python
from flask import Flask, Response, abort, request
from pidgeon.sign_in import pidgeon_sign_in

pidgeon = pidgeon_sign_in(client_id=CLIENT_ID, url="https://shop.example", secret=SESSION_SECRET)

@app.route("/api/auth/pidgeon/<action>", methods=["GET", "POST"])
def pidgeon_routes(action):
  answer = pidgeon.handle(request.method, request.url, request.headers.items(), request.get_data())
  if answer is None:
      abort(404)
  return Response(answer.body, answer.status, answer.headers)
```

The two libraries make the same checks with the same messages, and read each
other's sessions.

## In the identity provider you already use

Add Pidgeon as an OpenID Connect provider, with
`https://post.pidgeon.email` as the issuer (discovery at
`https://post.pidgeon.email/.well-known/openid-configuration`). It is
configuration, not code.

First, register your app once, with your provider's callback URL. Keep the
`client_id` and the `client_secret` it answers with: the secret is shown once.

```sh
curl https://post.pidgeon.email/api/post/oidc/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Your app",
    "redirect_uris": ["YOUR_CALLBACK_URL"],
    "token_endpoint_auth_method": "client_secret_post",
    "id_token_signed_response_alg": "ES256"
  }'
```

A provider that verifies EdDSA and needs no secret (Better Auth, Auth.js) can
leave out the last two lines and register as a public client with PKCE.

Ask for the scopes `openid email`, and `owner` to learn the person who answers
for the agent. Then, provider by provider:

- **Auth0**: Authentication → Enterprise → OpenID Connect. Discovery URL as
  above, communication channel **Back Channel**, the client ID and secret.
  The callback to register is `https://YOUR_AUTH0_DOMAIN/login/callback`.
  Register with `ES256`: Auth0 does not verify EdDSA.

- **Clerk**: a custom OpenID Connect provider, with the discovery URL, client ID
  and secret, and PKCE on. Create it first: Clerk shows the redirect URL to
  register on the connection's page. Register with `ES256`.

- **Supabase**: Authentication → a custom OIDC provider, identifier
  `custom:pidgeon`, the issuer, client ID and secret. The form shows the
  callback URL to register. Sign in with
  `supabase.auth.signInWithOAuth({ provider: 'custom:pidgeon' })`.

- **Better Auth**: the `genericOAuth` plugin.

  ```ts
  genericOAuth({
    config: [
      {
        providerId: 'pidgeon',
        discoveryUrl: 'https://post.pidgeon.email/.well-known/openid-configuration',
        clientId: process.env.PIDGEON_CLIENT_ID!,
        scopes: ['openid', 'email', 'owner'],
        pkce: true,
        tokenEndpointAuth: { method: 'none' },
      },
    ],
  });
  ```

  The callback is `https://your-app/api/auth/callback/pidgeon`, and the
  sign-in starts with `authClient.signIn.social({ provider: 'pidgeon' })`
  (before Better Auth 1.7: `/api/auth/oauth2/callback/pidgeon` and
  `signIn.oauth2`).

- **Auth.js**: an `oidc` provider.

  ```ts
  {
    id: 'pidgeon',
    name: 'Pidgeon',
    type: 'oidc',
    issuer: 'https://post.pidgeon.email',
    clientId: process.env.PIDGEON_CLIENT_ID,
    client: { token_endpoint_auth_method: 'none' },
    checks: ['pkce', 'state'],
    authorization: { params: { scope: 'openid email owner' } },
  }
  ```

  The callback is `https://your-app/api/auth/callback/pidgeon`.

Whichever it is, key the account on `sub`, never on `email`, and map
`actor_type` and `on_behalf_of` into the user if your provider lets you: they
say it is an agent and who it works for.

To see both sides run, there is an app and an agent signing in to it, with
[openid-client](https://github.com/panva/openid-client):
[`sign-in-with-address`](https://github.com/pidgeon-ai/pidgeon-examples/tree/main/sign-in-with-address).
