* Re-use code definition of oauthResponseTypeSchema * Generate proper invalid_authorization_details * Remove OpenID compatibility * tidy * properly verify presence of jti claim in client assertion * Remove non-standard "sub" from OAuthTokenResponse * Remove nonce from authorization request * tidy * Enforce uniqueness of code_challenge * remove unused "atproto" scope * Improve reporting of validation errors * Allow empty set of scopes * Do not remove scopes not advertised in the AS's "scopes_supported" when building the authorization request. * Prevent empty scope string * Remove invalid check from token response * remove un-necessary session refresh * Validate scopes characters according to OAuth 2.1 spec * Mandate the use of "atproto" scope * Disable ability to list app passwords when using an app password * Use locally defined authPassthru in com.atproto.admin.* handlers * provide proper production handle resolver in example * properly compote login method * feat(oauth-provider): always rotate session cookie on sign-in * feat(oauth-provider): do not require consent from first party apps * update request parameter's prompt before other param validation checks * feat(oauth-provider): rework display of client name * feat(oauth-client-browser:example): add token info introspection * feat(oauth-client-browser:example): allow defining scope globally * Display requested scopes during the auth flow * Add, and verify, a "typ" header to access and refresh tokens * Ignore case when checking for dpop auth scheme * Add "jwtAlg" option to verifySignature() function * Verify service JWT header values. Add iat claim to service JWT * Add support for "transition:generic" and "transition:chat.bsky" oauth scopes in PDS * oauth-client-browser(example): add scope request * Add missing "atproto" scope * Allow missing 'typ' claim in service auth jwt * Improved 401 feedback Co-authored-by: devin ivy <devinivy@gmail.com> * Properly parse scopes upon verification Co-authored-by: devin ivy <devinivy@gmail.com> * Rename "atp" to "credential" auth in oauth-client-browser example * add key to iteration items * Make CORS protection stronger * Allow OAuthProvider to define its own CORS policies * Revert "Allow missing 'typ' claim in service auth jwt" This reverts commit 15c6b9e2197064eb5de61a96de6497060edb824e. * Revert "Verify service JWT header values. Add iat claim to service JWT" This reverts commit 08df8df322a3f4b631c4a63a61d55b2c84c60c11. * Revert "Add "jwtAlg" option to verifySignature() function" This reverts commit d0f77354e6904678e7f5d76bb026f07537443ba9. * Revert "Add, and verify, a "typ" header to access and refresh tokens" This reverts commit 3e21be9e4b5875caa5e862c11f2196786fb2366d. * pds: implement protected service auth methods * Prevent app password management using sessions initiated from an app password. * Alphabetically sort PROTECTED_METHODS * Revert changes to app password management permissions * tidy --------- Co-authored-by: devin ivy <devinivy@gmail.com>
8.7 KiB
OAuth Client Quickstart
This document describes how to implement OAuth based authentication in a browser-based Single Page App (SPA), to communicate with atproto API services.
Prerequisites
- 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.
Step 1: Create your client metadata
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
orlogo_uri
to the user. If you don't see your logo or client name during the authentication process, don't worry. This is normal. Theclient_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
.
Step 2: Setup your SPA
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
}
Step 3: Implement the OAuth flow
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 forhandleResolver
.
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)
}