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.
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:
- Serve
oauth.metadataat/oauth-client-metadata.json, which is how the user's PDS identifies your app. - Redirect the user to
await oauth.authorize(handle). - On return, read the session with
await oauth.callback(params)and pass it tocreateAirspace({ 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:
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:
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():
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:
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.
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:
handleResolver: 'https://your-pds.example.com' // any PDS resolves handlesDIDs 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:
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:
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.
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:
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 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:
import { spacesSupported } from 'airspace/oauth'
const spaces = await spacesSupported(service) ? { workspace } : undefined
scopesFor({ collections: { projects, categories }, spaces })