This document describes how to implement OAuth based authentication in a browser-based Single Page App (SPA), to communicate with atproto API services.
- You need a web server - or at the very least a static file server - to host your SPA.
Tip
During development, you can use a local server to host your client metadata. You will need to use a tunneling service like ngrok to make your local server accessible from the internet.
Tip
You can use a service like GitHub Pages to host your client metadata and SPA for free.
- You must be able to build and deploy a SPA to your server.
Based on your hosting server endpoint, you will first need to choose a
client_id
. That client_id
will be used to identify your client to
Authorization Servers. A client_id
must be a URL pointing to a JSON file
which contains your client metadata. The client metadata must contain a
client_id
that is the URL used to access the metadata.
Here is an example client metadata.
{
"client_id": "https://example.com/client-metadata.json",
"client_name": "Example atproto Browser App",
"client_uri": "https://example.com",
"logo_uri": "https://example.com/logo.png",
"tos_uri": "https://example.com/tos",
"policy_uri": "https://example.com/policy",
"redirect_uris": ["https://example.com/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
-
redirect_uris
: An array of URLs that will be used as the redirect URIs for the OAuth flow. This should typically contain a single URL that points to a page on your SPA that will handle the OAuth response. This URL must be HTTPS. -
client_id
: The URL where the client metadata is hosted. This field must be the exact same as the URL used to access the metadata. -
client_name
: The name of your client. Will be displayed to the user during the authentication process. -
client_uri
: The URL of your client. Whether or not this value is actually displayed / used is up to the Authorization Server. -
logo_uri
: The URL of your client's logo. Should be displayed to the user during the authentication process. Whether your logo is actually displayed during the authentication process or not is up to the Authorization Server. -
tos_uri
: The URL of your client's terms of service. Will be displayed to the user during the authentication process. -
policy_uri
: The URL of your client's privacy policy. Will be displayed to the user during the authentication process. -
If you don't want or need the user to stay authenticated for long periods (better for security), you can remove
refresh_token
from thegrant_types
.
Note
To mitigate phishing attacks, the Authentication Server will typically not
display the client_uri
or logo_uri
to the user. If you don't see your logo
or client name during the authentication process, don't worry. This is normal.
The client_name
is generally displayed for all clients.
Upload this JSON file so that it is accessible at the URL you chose for your
client_id
.
Start by setting up your SPA. You can use any framework you like, or none at all. In this example, we will use TypeScript and Parcel, with plain JavaScript.
npm init -y
npm install --save-dev @atproto/oauth-client-browser
npm install --save-dev @atproto/api
npm install --save-dev parcel
npm install --save-dev parcel-reporter-static-files-copy
mkdir -p src
mkdir -p static
Create a .parcelrc
file with the following (exact) content:
{
"extends": ["@parcel/config-default"],
"reporters": ["...", "parcel-reporter-static-files-copy"]
}
Create an src/index.html
file with the following content:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My First OAuth App</title>
<script type="module" src="app.ts"></script>
</head>
<body>
Loading...
</body>
</html>
And an src/app.ts
file, with the following content:
console.log('Hello from atproto OAuth example app!')
Start the app in development mode:
npx parcel src/index.html
In another terminal, open a tunnel to your local server:
ngrok http 1234
Create a static/client-metadata.json
file with the client metadata you created
in Step 1. Use the hostname provided by
ngrok as the client_id
:
{
"client_id": "https://<RANDOM_VALUE>.ngrok.app/client-metadata.json",
"client_name": "My First atproto OAuth App",
"client_uri": "https://<RANDOM_VALUE>.ngrok.app",
"redirect_uris": ["https://<RANDOM_VALUE>.ngrok.app/"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
Replace the content of the src/app.ts
file, with the following content:
import { Agent } from '@atproto/api'
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
async function main() {
const oauthClient = await BrowserOAuthClient.load({
clientId: '<YOUR_CLIENT_ID>',
handleResolver: 'https://bsky.social/',
})
// TO BE CONTINUED
}
document.addEventListener('DOMContentLoaded', main)
Caution
Using Bluesky-hosted services for handle resolution (eg, the bsky.social
endpoint) will leak both user IP addresses and handle identifier to Bluesky,
a third party. While Bluesky has a declared privacy policy, both developers
and users of applications need to be informed of and aware of the privacy
implications of this arrangement. Application developers are encouraged to
improve user privacy by operating their own handle resolution service when
possible. If you are a PDS self-hoster, you can use your PDS's URL for
handleResolver
.
The oauthClient
is now configured to communicate with the user's
Authorization Service. You can now initialize it in order to detect if the user
is already authenticated. Replace the // TO BE CONTINUED
comment with the
following code:
const result = await oauthClient.init()
if (result) {
if ('state' in result) {
console.log('The user was just redirected back from the authorization page')
}
console.log(`The user is currently signed in as ${result.session.did}`)
}
const session = result?.session
// TO BE CONTINUED
At this point you can detect if the user is already authenticated or not (by
checking if session
is undefined
).
Let's initiate an authentication flow if the user is not authenticated. Replace
the // TO BE CONTINUED
comment with the following code:
if (!session) {
const handle = prompt('Enter your atproto handle to authenticate')
if (!handle) throw new Error('Authentication process canceled by the user')
const url = await oauthClient.authorize(handle)
// Redirect the user to the authorization page
window.open(url, '_self', 'noopener')
// Protect against browser's back-forward cache
await new Promise<never>((resolve, reject) => {
setTimeout(
reject,
10_000,
new Error('User navigated back from the authorization page'),
)
})
}
// TO BE CONTINUED
At this point in the script, the user will be authenticated. Authenticated
API calls can be made using the session
. The session
can be used to instantiate the
Agent
class from @atproto/api
. Let's make a simple call to the API to
retrieve the user's profile. Replace the // TO BE CONTINUED
comment with the
following code:
if (session) {
const agent = new Agent(session)
const fetchProfile = async () => {
const profile = await agent.getProfile({ actor: agent.did })
return profile.data
}
// Update the user interface
document.body.textContent = `Authenticated as ${agent.did}`
const profileBtn = document.createElement('button')
document.body.appendChild(profileBtn)
profileBtn.textContent = 'Fetch Profile'
profileBtn.onclick = async () => {
const profile = await fetchProfile()
outputPre.textContent = JSON.stringify(profile, null, 2)
}
const logoutBtn = document.createElement('button')
document.body.appendChild(logoutBtn)
logoutBtn.textContent = 'Logout'
logoutBtn.onclick = async () => {
await session.signOut()
window.location.reload()
}
const outputPre = document.createElement('pre')
document.body.appendChild(outputPre)
}