# OAuth and permission sets

An app password is fine for your own account. To act on someone else's behalf, use OAuth: they log in at their own PDS and grant your app a list of permissions, called scopes. `scopesFor` builds that list from your model.

```ts
import { scopesFor } from 'airspace'
import { createOAuth } from 'airspace/oauth' // optional peer: @atproto/oauth-client-node

const oauth = await createOAuth({
  baseUrl: 'https://roe.dev',
  redirectPath: '/api/admin/auth/callback',
  name: 'roe.dev admin',
  scopes: scopesFor({ collections: { projects, categories }, spaces: { workspace } }),
  stores: { session: mySessionStore },
})
```

Then wire up three things:

1. Serve `oauth.metadata` at `/oauth-client-metadata.json`, which is how the user's PDS identifies your app.
2. Redirect the user to `await oauth.authorize(handle)`.
3. On return, read the session with `await oauth.callback(params)` and pass it to `createAirspace({ session })`.

Refresh tokens are single-use. If your server runs as several instances, pass `requestLock` so only one of them refreshes a given session at a time:

```ts
const oauth = await createOAuth({
  // ...
  requestLock: (name, fn) => redisLock(name, fn),
})
```

Without one, a single-instance server is still safe and can ignore the `No lock mechanism provided` warning.

File scopes match the types your blob fields accept, so a model that only accepts `image/*` asks for `blob:image/*`, not `blob:*/*`.

## asking for part of the scopes

`authorize` takes a subset of the client's scopes, so login can ask for the collections and a space can be granted in a later consent. A stock PDS rejects `space:` scopes it does not know, which would otherwise break login:

```ts
const url = await oauth.authorize(handle, {
  scopes: ['atproto', 'repo:dev.example.project'],
})
```

Every consent includes `atproto`, and a scope the client did not declare throws before the request leaves your app. `signIn` in the browser takes the same `scopes`.

## managing your own spaces

A space of your own is asked for with `manage=create`, which is enough to create it and write into it. Each operation is granted on its own, so ask for `update` to call `manage.update()` (or `manage.ensure()` on a space that already exists) and `delete` to call `manage.delete()`:

```ts
scopesFor({ spaces: { workspace }, manage: ['create', 'update'] })
// ... 'space:dev.roe.workspace?skey=self&manage=create&manage=update'
```

With more than one space of your own, key `manage` by the same names you passed to `spaces`:

```ts
scopesFor({ spaces: { workspace, archive }, manage: { archive: ['create', 'delete'] } })
```

Spaces you leave out keep the `['create']` default; pass `[]` to ask for nothing.

## in the browser

`airspace/oauth/browser` runs the same handshake in the page, so your server never sees a token. Storage is handled by `@atproto/oauth-client-browser`, in IndexedDB, so there are no stores to provide.

```ts
import { scopesFor } from 'airspace'
import { createBrowserOAuth } from 'airspace/oauth/browser' // optional peer: @atproto/oauth-client-browser

const oauth = await createBrowserOAuth({
  baseUrl: 'https://unifont.dev',
  redirectPath: '/stack',
  name: 'unifont.dev',
  scopes: scopesFor({ collections: { stacks } }),
})

const result = await oauth.init()
if (result) {
  const airspace = createAirspace({ identity: { did: result.did }, collections: { stacks }, session: result.session })
}
else {
  await oauth.signIn('danielroe.dev') // navigates to the user's PDS
}
```

`init()` handles a redirect back from the PDS, restores a stored session on a later page load, or returns `null` when there is neither. `redirectPath` is a page in your app rather than an API route. `revoke(did)` signs the user out: it revokes the tokens at their authorization server as well as dropping the stored session, the same as on the server.

### handle resolution

A browser cannot query DNS, so a handle such as `danielroe.dev` is resolved by asking a service over HTTP. That service sees every handle your users type, and by default it is `https://bsky.social`, which is a third party. Point it somewhere you trust:

```ts
handleResolver: 'https://your-pds.example.com' // any PDS resolves handles
```

DIDs are resolved directly and never go through it.

### serving the metadata document

A browser app still has to serve `/oauth-client-metadata.json`. `airspace/oauth/metadata` builds that document with no OAuth client behind it, so a server route can use it without either atproto client installed:

```ts
import { clientMetadata } from 'airspace/oauth/metadata'

export default defineEventHandler(() => clientMetadata({
  baseUrl: 'https://unifont.dev',
  redirectPath: '/stack',
  name: 'unifont.dev',
  scopes: scopesFor({ collections: { stacks } }),
}))
```

Both entry points produce the same document, so `clientMetadata` from `airspace/oauth` works too if the server already depends on `@atproto/oauth-client-node`.

## against a local PDS

Handle and account lookups go to DNS and `plc.directory`, neither of which knows about a local account such as `alice.test`. Point both at your development network:

```ts
const local = {
  allowHttp: true,
  handleResolver: 'http://localhost:2583', // the PDS resolves its own handles
  plcDirectoryUrl: 'http://localhost:41937', // the port `pnpm dev:pds` prints
}
```

## permission sets

A consent screen listing one scope per collection is hard to read. A permission set bundles them into a single named permission, so the user sees one line instead.

```ts
import { defineLexicons, field, permissions } from 'airspace/lexicon'

export default defineLexicons('dev.roe', {
  project: { name: field.text(), cover: field.image().optional() },
  projectCategory: { name: field.text() },
  authFull: permissions({
    collections: ['project', 'projectCategory'],
    title: 'Manage projects',
    detail: 'Read and write your projects and their categories.',
  }),
})
```

Everything in a set must sit under your own namespace, or airspace throws at definition time. To use one, pass it instead of a list of collections:

```ts
scopesFor({ collections: { projects, categories }, include: [lexicons.authFull] })
// ['atproto', 'include:dev.roe.authFull', 'blob:image/*']
```

Someone else's set works too, by name: `include: ['site.standard.authFull']`. File permissions are never part of a set, so a `blob:` scope is added alongside.

A set must be [published](/docs/publishing-lexicons) before anyone can ask for it, as must a `space:` scope. Until then, `authorize()` fails with `invalid_scope: Could not resolve Lexicon for NSID`. While developing, list your collections instead.

A `space:` scope also fails the login on a PDS that does not serve spaces. `spacesSupported()` can tell if a PDS supports spaces before signing in, so make sure you leave `spaces` out of `scopesFor()` when it is false:

```ts
import { spacesSupported } from 'airspace/oauth'

const spaces = await spacesSupported(service) ? { workspace } : undefined
scopesFor({ collections: { projects, categories }, spaces })
```
