Gathering detailed insights and metrics for @atcute/oauth-browser-client
Gathering detailed insights and metrics for @atcute/oauth-browser-client
a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky.
npm install @atcute/oauth-browser-client
Typescript
Module System
Node Version
NPM Version
74.8
Supply Chain
99
Quality
88.4
Maintenance
100
Vulnerability
100
License
TypeScript (98.57%)
JavaScript (1.21%)
Shell (0.22%)
Total Downloads
1,607
Last Day
5
Last Week
92
Last Month
712
Last Year
1,607
256 Stars
387 Commits
6 Forks
2 Watching
2 Branches
7 Contributors
Latest Version
1.0.9
Package Id
@atcute/oauth-browser-client@1.0.9
Unpacked Size
141.88 kB
Size
37.19 kB
File Count
91
NPM Version
10.9.0
Node Version
22.12.0
Publised On
02 Jan 2025
Cumulative downloads
Total Downloads
Last day
-66.7%
5
Compared to previous day
Last week
-61.5%
92
Compared to previous week
Last month
32.3%
712
Compared to previous month
Last year
0%
1,607
Compared to previous year
1
1
minimal OAuth browser client implementation for AT Protocol.
initialize the client by importing and calling configureOAuth
with the client ID and redirect URL.
this call should be placed before any other calls you make with this library.
1import { configureOAuth } from '@atcute/oauth-browser-client';
2
3configureOAuth({
4 metadata: {
5 client_id: 'https://example.com/oauth/client-metadata.json',
6 redirect_uri: 'https://example.com/oauth/callback',
7 },
8});
[!CAUTION]
the built-in handle resolution makes use of Bluesky-hosted services to return the intended DID, and this can mean sharing the user's IP address and the handle identifiers to Bluesky.while Bluesky has a declared privacy policy, both developers and users need to be informed and aware of the privacy implications of this arrangement. read this guide on how you can implement your own resolution code.
if your application involves asking for the user's handle or DID, you can use resolveFromIdentity
which resolves the user's identity to get its PDS, and the metadata of its authorization server.
1import { resolveFromIdentity } from '@atcute/oauth-browser-client'; 2 3const { identity, metadata } = await resolveFromIdentity('mary.my.id');
alternatively, if it involves asking for the user's PDS, then you can use resolveFromService
which
just grabs the authorization server metadata.
1import { resolveFromService } from '@atcute/oauth-browser-client'; 2 3const { metadata } = await resolveFromService('bsky.social');
we can then proceed with authorization by calling createAuthorizationUrl
with the resolved
metadata
(and identity
, if using resolveFromIdentity
) along with the scope of the
authorization, which should either match the one in your client metadata, or a reduced set of it.
1import { createAuthorizationUrl } from '@atcute/oauth-browser-client';
2
3// passing `identity` is optional,
4// it allows for the login form to be autofilled with the user's handle or DID
5const authUrl = await createAuthorizationUrl({
6 metadata: metadata,
7 identity: identity,
8 scope: 'atproto transition:generic transition:chat.bsky',
9});
10
11// recommended to wait for the browser to persist local storage before proceeding
12await sleep(200);
13
14// redirect the user to sign in and authorize the app
15window.location.assign(authUrl);
16
17// if this is on an async function, ideally the function should never ever resolve.
18// the only way it should resolve at this point is if the user aborted the authorization
19// by returning back to this page (thanks to back-forward page caching)
20await new Promise((_resolve, reject) => {
21 const listener = () => {
22 reject(new Error(`user aborted the login request`));
23 };
24
25 window.addEventListener('pageshow', listener, { once: true });
26});
once the user has been redirected to your redirect URL, we can call finalizeAuthorization
with the
parameters that have been provided.
1import { XRPC } from '@atcute/client'; 2import { OAuthUserAgent, finalizeAuthorization } from '@atcute/oauth-browser-client'; 3 4// `createAuthorizationUrl` asks for the server to redirect here with the 5// parameters assigned in the hash, not the search string. 6const params = new URLSearchParams(location.hash.slice(1)); 7 8// this is optional, but after retrieving the parameters, we should ideally 9// scrub it from history to prevent this authorization state to be replayed, 10// just for good measure. 11history.replaceState(null, '', location.pathname + location.search); 12 13// you'd be given a session object that you can then pass to OAuthUserAgent! 14const session = await finalizeAuthorization(params); 15 16// now you can start making requests! 17const agent = new OAuthUserAgent(session); 18 19// pass it onto the XRPC so you can make RPC calls with the PDS. 20{ 21 const rpc = new XRPC({ handler: agent }); 22 23 const { data } = await rpc.get('com.atproto.identity.resolveHandle', { 24 params: { 25 handle: 'mary.my.id', 26 }, 27 }); 28} 29 30// or, use it directly! 31{ 32 const response = await agent.handle('/xrpc/com.atproto.identity.resolveHandle?handle=mary.my.id'); 33}
the session
object returned by finalizeAuthorization
should not be stored anywhere else, as it
is already persisted in the internal database. you are expected to keep track of who's signed in and
who was last signed in for your own UI, as the sessions stored by the database is not guaranteed to
be permanent (mostly if they don't come with a refresh token.)
you can resume existing sessions by calling getSession
with the DID identifier you intend to
resume.
1import { XRPC } from '@atcute/client'; 2import { OAuthUserAgent, getSession } from '@atcute/oauth-browser-client'; 3 4const session = await getSession('did:plc:ia76kvnndjutgedggx2ibrem', { allowStale: true }); 5 6const agent = new OAuthUserAgent(session); 7const rpc = new XRPC({ handler: agent });
you can manually remove sessions via deleteStoredSession
, but ideally, you should revoke the token
first before doing so.
1import { OAuthUserAgent, deleteStoredSession, getSession } from '@atcute/oauth-browser-client'; 2 3const did = 'did:plc:ia76kvnndjutgedggx2ibrem'; 4 5try { 6 const session = await getSession(did, { allowStale: true }); 7 const agent = new OAuthUserAgent(session); 8 9 await agent.signOut(); 10} catch (err) { 11 // `signOut` also deletes the session, we only serve as fallback if it fails. 12 deleteStoredSession(did); 13}
you might want to configure the server options in your Vite config so you'll never end up visiting
your app in localhost
, which is specifically forbidden by AT Protocol's OAuth, let's change it so
it'll always use 127.0.0.1
:
1/// vite.config.ts 2import { defineConfig } from 'vite'; 3 4const SERVER_HOST = '127.0.0.1'; 5const SERVER_PORT = 12520; 6 7export default defineConfig({ 8 server: { 9 host: SERVER_HOST, 10 port: SERVER_PORT, 11 }, 12});
additionally, to make it easier to develop locally and deploy to production, you should consider adding a plugin that'll inject the necessary values for you through environment variables:
1/// vite.config.ts 2import metadata from './public/oauth/client-metadata.json' with { type: 'json' }; 3 4export default defineConfig({ 5 // ... 6 7 plugins: [ 8 // injects OAuth-related environment variables 9 { 10 config(_conf, { command }) { 11 if (command === 'build') { 12 process.env.VITE_OAUTH_CLIENT_ID = metadata.client_id; 13 process.env.VITE_OAUTH_REDIRECT_URI = metadata.redirect_uris[0]; 14 } else { 15 const redirectUri = (() => { 16 const url = new URL(metadata.redirect_uris[0]); 17 return `http://${SERVER_HOST}:${SERVER_PORT}${url.pathname}`; 18 })(); 19 20 const clientId = 21 `http://localhost` + 22 `?redirect_uri=${encodeURIComponent(redirectUri)}` + 23 `&scope=${encodeURIComponent(metadata.scope)}`; 24 25 process.env.VITE_DEV_SERVER_PORT = '' + SERVER_PORT; 26 process.env.VITE_OAUTH_CLIENT_ID = clientId; 27 process.env.VITE_OAUTH_REDIRECT_URI = redirectUri; 28 } 29 30 process.env.VITE_CLIENT_URI = metadata.client_uri; 31 process.env.VITE_OAUTH_SCOPE = metadata.scope; 32 }, 33 }, 34 ], 35});
we'll augment the type declarations to get type-checking on it:
1/// src/vite-env.d.ts 2 3interface ImportMetaEnv { 4 readonly VITE_DEV_SERVER_PORT?: string; 5 readonly VITE_CLIENT_URI: string; 6 readonly VITE_OAUTH_CLIENT_ID: string; 7 readonly VITE_OAUTH_REDIRECT_URI: string; 8 readonly VITE_OAUTH_SCOPE: string; 9} 10 11interface ImportMeta { 12 readonly env: ImportMetaEnv; 13}
et voilĂ ! you can now use this to configure the client.
1configureOAuth({
2 metadata: {
3 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID,
4 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI,
5 },
6});
7
8// ... later during sign-in process
9const authUrl = await createAuthorizationUrl({
10 metadata: metadata,
11 identity: identity,
12 scope: import.meta.env.VITE_OAUTH_SCOPE,
13});
adjust the code here as necessary, the plugin adds more environment variables than what is actually needed, you can remove them if you don't think you'd need it.
there are two ways that a handle can be verified:
/.well-known/atproto-did
containing your account's DID_atproto
TXT record containing your account's DIDyou'd want to resolve both of these. if both methods return a response but does not match each other then it should ideally be thrown.
verify that the DID matches the intended format
1const isDid = (did: string): did is At.DID => { 2 return /^did:([a-z]+):([a-zA-Z0-9._:%-]*[a-zA-Z0-9._-])$/.test(did); 3};
pass this resolved DID to resolveFromIdentity
, and carry on as per usual.
this is very straightforward, make a request to https://<handle>/.well-known/atproto-did
without
following redirects. check if the response status is 200 and trim off any excess whitespaces.
some web servers might not set a permissible CORS header to access this resource, in which case there is nothing that can be done, unless you'd want to proxy the requests.
1const resolveHandleViaHttp = async (handle: string): Promise<At.DID> => { 2 const url = new URL('/.well-known/atproto-did', `https://${handle}`); 3 4 const response = await fetch(url, { redirect: 'error' }); 5 if (!response.ok) { 6 throw new ResolverError(`domain is unreachable`); 7 } 8 9 const text = await response.text(); 10 11 const did = text.split('\n')[0]!.trim(); 12 if (isDid(did)) { 13 return did; 14 } 15 16 throw new ResolverError(`failed to resolve ${handle}`); 17};
as websites can't do DNS resolution on their own, we'd have to rely on DNS-over-HTTPS (DoH) services. it should be noted that this can have privacy implications of its own, please read through the privacy policy of whichever DoH service you end up using and make the user aware of it as well.
for this example, we'll be using Cloudflare's DoH resolver for Firefox (privacy
policy) as it has support for application/dns-json
format which
allows us to query and see the responses in JSON.
1const SUBDOMAIN = '_atproto'; 2const PREFIX = 'did='; 3 4const resolveHandleViaDoH = async (handle: string): Promise<At.DID> => { 5 const url = new URL('https://mozilla.cloudflare-dns.com/dns-query'); 6 url.searchParams.set('type', 'TXT'); 7 url.searchParams.set('name', `${SUBDOMAIN}.${handle}`); 8 9 const response = await fetch(url, { 10 method: 'GET', 11 headers: { accept: 'application/dns-json' }, 12 redirect: 'follow', 13 }); 14 15 const type = response.headers.get('content-type')?.trim(); 16 if (!response.ok) { 17 const message = type?.startsWith('text/plain') 18 ? await response.text() 19 : `failed to resolve ${handle}`; 20 21 throw new ResolverError(message); 22 } 23 24 if (type !== 'application/dns-json') { 25 throw new ResolverError(`unexpected response from DoH server`); 26 } 27 28 const result = asResult(await response.json()); 29 const answers = result.Answer?.filter(isAnswerTxt).map(extractTxtData) ?? []; 30 31 for (let i = 0; i < answers.length; i++) { 32 // skip if the line does not start with "did=" 33 if (!answers[i].startsWith(PREFIX)) { 34 continue; 35 } 36 37 // ensure there is no other entry starting with "did=" 38 for (let j = i + 1; j < answers.length; j++) { 39 if (answers[j].startsWith(PREFIX)) { 40 break; 41 } 42 } 43 44 const did = answers[i].slice(PREFIX.length); 45 if (isDid(did)) { 46 return did; 47 } 48 49 break; 50 } 51 52 throw new ResolverError(`failed to resolve ${handle}`); 53}; 54 55type Result = { Status: number; Answer?: Answer[] }; 56const isResult = (result: unknown): result is Result => { 57 if (result === null || typeof result !== 'object') { 58 return false; 59 } 60 61 return ( 62 'Status' in result && 63 typeof result.Status === 'number' && 64 (!('Answer' in result) || (Array.isArray(result.Answer) && result.Answer.every(isAnswer))) 65 ); 66}; 67const asResult = (result: unknown): Result => { 68 if (!isResult(result)) { 69 throw new TypeError(`unexpected DoH response`); 70 } 71 72 return result; 73}; 74 75type Answer = { name: string; type: number; data: string; TTL: number }; 76const isAnswer = (answer: unknown): answer is Answer => { 77 if (answer === null || typeof answer !== 'object') { 78 return false; 79 } 80 81 return ( 82 'name' in answer && 83 typeof answer.name === 'string' && 84 'type' in answer && 85 typeof answer.type === 'number' && 86 'data' in answer && 87 typeof answer.data === 'string' && 88 'TTL' in answer && 89 typeof answer.TTL === 'number' 90 ); 91}; 92 93type AnswerTxt = Answer & { type: 16 }; 94const isAnswerTxt = (answer: Answer): answer is AnswerTxt => { 95 return answer.type === 16; 96}; 97 98const extractTxtData = (answer: AnswerTxt): string => { 99 return answer.data.replace(/^"|"$/g, '').replace(/\\"/g, '"'); 100};
alternatively, if you operate your own PDS, you can make use of it as a handle resolver.
1const resolveHandleViaPds = async (handle: string): Promise<At.DID> => { 2 const rpc = new XRPC({ handler: simpleFetchHandler({ service: `https://my-pds.example.com` }) }); 3 4 const { data } = await rpc.get('com.atproto.identity.resolveHandle', { 5 params: { 6 handle: handle, 7 }, 8 }); 9 10 return data.did; 11};
No vulnerabilities found.
No security vulnerabilities found.