Skip to content

Commit 848f115

Browse files
committed
feat(bluesky): initial commit
1 parent a4d3235 commit 848f115

File tree

7 files changed

+2733
-0
lines changed

7 files changed

+2733
-0
lines changed

packages/bluesky/lib/index.ts

+361
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import { fetchHandler, isErrorResponse, type XRPC, XRPCError, type XRPCRequest } from '@atcute/client';
2+
import type {
3+
At,
4+
ComAtprotoServerCreateSession,
5+
ComAtprotoServerRefreshSession,
6+
} from '@atcute/client/lexicons';
7+
8+
import './lexicons.js';
9+
10+
import { decodeJwt } from './utils/jwt.js';
11+
12+
/** Interface for the decoded access token, for convenience */
13+
export interface AtpAccessJwt {
14+
/** Access token scope, app password returns a different scope. */
15+
scope: 'com.atproto.access' | 'com.atproto.appPass' | 'com.atproto.appPassPrivileged';
16+
/** Account DID */
17+
sub: At.DID;
18+
/** Expiration time */
19+
exp: number;
20+
/** Creation/issued time */
21+
iat: number;
22+
}
23+
24+
/** Interface for the decoded refresh token, for convenience */
25+
export interface AtpRefreshJwt {
26+
/** Refresh token scope */
27+
scope: 'com.atproto.refresh';
28+
/** ID of this refresh token */
29+
jti: string;
30+
/** Account DID */
31+
sub: At.DID;
32+
/** Intended audience of this refresh token, in DID */
33+
aud: At.DID;
34+
/** Expiration time */
35+
exp: number;
36+
/** Creation/issued time */
37+
iat: number;
38+
}
39+
40+
/** Saved session data, this can be reused again for next time. */
41+
export interface AtpSessionData {
42+
/** Refresh token */
43+
refreshJwt: string;
44+
/** Access token */
45+
accessJwt: string;
46+
/** Account handle */
47+
handle: string;
48+
/** Account DID */
49+
did: At.DID;
50+
/** PDS endpoint found in the DID document, this will be used as the service URI if provided */
51+
pdsUri?: string;
52+
/** Email address of the account, might not be available if on app password */
53+
email?: string;
54+
/** If the email address has been confirmed or not */
55+
emailConfirmed?: boolean;
56+
/** If the account has email-based two-factor authentication enabled */
57+
emailAuthFactor?: boolean;
58+
/** Whether the account is active (not deactivated, taken down, or suspended) */
59+
active: boolean;
60+
/** Possible reason for why the account is inactive */
61+
inactiveStatus?: string;
62+
}
63+
64+
/** Additional options for constructing an authentication middleware */
65+
export interface BskyAuthOptions {
66+
/** This function gets called if the session turned out to have expired during an XRPC request */
67+
onExpired?: (session: AtpSessionData) => void;
68+
/** This function gets called if the session has been refreshed during an XRPC request */
69+
onRefresh?: (session: AtpSessionData) => void;
70+
/** This function gets called if the session object has been refreshed */
71+
onSessionUpdate?: (session: AtpSessionData) => void;
72+
}
73+
74+
/** Authentication/session management middleware */
75+
export class BskyAuth {
76+
#rpc: XRPC;
77+
#refreshSessionPromise?: Promise<void>;
78+
79+
#onExpired: BskyAuthOptions['onExpired'];
80+
#onRefresh: BskyAuthOptions['onRefresh'];
81+
#onSessionUpdate: BskyAuthOptions['onSessionUpdate'];
82+
83+
/** Current session state */
84+
session?: AtpSessionData;
85+
86+
constructor(rpc: XRPC, { onExpired, onRefresh, onSessionUpdate }: BskyAuthOptions = {}) {
87+
this.#rpc = rpc;
88+
89+
this.#onRefresh = onRefresh;
90+
this.#onExpired = onExpired;
91+
this.#onSessionUpdate = onSessionUpdate;
92+
93+
rpc.hook((next) => async (request) => {
94+
await this.#refreshSessionPromise;
95+
96+
let res = await next(this.#decorateRequest(request));
97+
98+
if (isErrorResponse(res.body, ['ExpiredToken']) && this.session?.refreshJwt) {
99+
await this.#refreshSession();
100+
101+
if (this.session) {
102+
// retry fetch
103+
res = await next(this.#decorateRequest(request));
104+
}
105+
}
106+
107+
return res;
108+
});
109+
}
110+
111+
#decorateRequest(req: XRPCRequest): XRPCRequest {
112+
const session = this.session;
113+
114+
if (session && !req.headers['Authorization']) {
115+
return {
116+
...req,
117+
service: session.pdsUri || req.service,
118+
headers: {
119+
...req.headers,
120+
Authorization: `Bearer ${session.accessJwt}`,
121+
},
122+
};
123+
}
124+
125+
return req;
126+
}
127+
128+
#refreshSession() {
129+
return (this.#refreshSessionPromise ||= this.#refreshSessionInner().finally(() => {
130+
this.#refreshSessionPromise = undefined;
131+
}));
132+
}
133+
134+
async #refreshSessionInner() {
135+
const session = this.session!;
136+
137+
if (!session || !session.refreshJwt) {
138+
return;
139+
}
140+
141+
const res = await fetchHandler({
142+
service: session.pdsUri || this.#rpc.service,
143+
type: 'post',
144+
nsid: 'com.atproto.server.refreshSession',
145+
headers: {
146+
Authorization: `Bearer ${session.refreshJwt}`,
147+
},
148+
params: {},
149+
});
150+
151+
if (isErrorResponse(res.body, ['ExpiredToken', 'InvalidToken'])) {
152+
// failed due to a bad refresh token
153+
this.session = undefined;
154+
this.#onExpired?.(session);
155+
} else if (res.status === 200) {
156+
// succeeded, update the session
157+
this.#updateSession({ ...session, ...(res.body as ComAtprotoServerRefreshSession.Output) });
158+
this.#onRefresh?.(this.session!);
159+
}
160+
}
161+
162+
#updateSession(raw: ComAtprotoServerCreateSession.Output): AtpSessionData {
163+
const didDoc = raw.didDoc as DidDocument | undefined;
164+
165+
let pdsUri: string | undefined;
166+
if (didDoc) {
167+
pdsUri = getPdsEndpoint(didDoc);
168+
}
169+
170+
const newSession = {
171+
accessJwt: raw.accessJwt,
172+
refreshJwt: raw.refreshJwt,
173+
handle: raw.handle,
174+
did: raw.did,
175+
pdsUri: pdsUri,
176+
email: raw.email,
177+
emailConfirmed: raw.emailConfirmed,
178+
emailAuthFactor: raw.emailConfirmed,
179+
active: raw.active ?? true,
180+
inactiveStatus: raw.status,
181+
};
182+
183+
this.session = newSession;
184+
this.#onSessionUpdate?.(newSession);
185+
186+
return newSession;
187+
}
188+
189+
/**
190+
* Resume a saved session
191+
* @param session Session information, taken from `BskyAuth#session` after login
192+
*/
193+
async resume(session: AtpSessionData): Promise<AtpSessionData> {
194+
const now = Date.now() / 1000 + 60 * 5;
195+
196+
const refreshToken = decodeJwt(session.refreshJwt) as AtpRefreshJwt;
197+
198+
if (now >= refreshToken.exp) {
199+
throw new XRPCError(401, { kind: 'InvalidToken' });
200+
}
201+
202+
const accessToken = decodeJwt(session.accessJwt) as AtpAccessJwt;
203+
this.session = session;
204+
205+
if (now >= accessToken.exp) {
206+
await this.#refreshSession();
207+
} else {
208+
const promise = this.#rpc.get('com.atproto.server.getSession', {});
209+
210+
promise.then((response) => {
211+
const existing = this.session;
212+
const next = response.data;
213+
214+
if (!existing) {
215+
return;
216+
}
217+
218+
this.#updateSession({ ...existing, ...next });
219+
});
220+
}
221+
222+
if (!this.session) {
223+
throw new XRPCError(401, { kind: 'InvalidToken' });
224+
}
225+
226+
return this.session;
227+
}
228+
229+
/**
230+
* Perform a login operation
231+
* @param options Login options
232+
* @returns Session data that can be saved for later
233+
*/
234+
async login(options: AuthLoginOptions): Promise<AtpSessionData> {
235+
// Reset the session
236+
this.session = undefined;
237+
238+
const res = await this.#rpc.call('com.atproto.server.createSession', {
239+
data: {
240+
identifier: options.identifier,
241+
password: options.password,
242+
authFactorToken: options.code,
243+
},
244+
});
245+
246+
return this.#updateSession(res.data);
247+
}
248+
}
249+
250+
/** Login options */
251+
export interface AuthLoginOptions {
252+
/** What account to login as, this could be domain handle, DID, or email address */
253+
identifier: string;
254+
/** Account password */
255+
password: string;
256+
/** Two-factor authentication code */
257+
code?: string;
258+
}
259+
260+
/** Options for constructing a moderation middleware */
261+
export interface BskyModOptions {
262+
/** List of moderation services to use */
263+
labelers?: ModerationService[];
264+
}
265+
266+
/** Moderation middleware, unstable. */
267+
export class BskyMod {
268+
/** List of moderation services that gets forwarded as a header */
269+
labelers: ModerationService[];
270+
271+
constructor(rpc: XRPC, { labelers = [] }: BskyModOptions = {}) {
272+
this.labelers = labelers;
273+
274+
rpc.hook((next) => (request) => {
275+
return next({
276+
...request,
277+
headers: {
278+
...request.headers,
279+
'atproto-accept-labelers': this.labelers
280+
.map((labeler) => labeler.did + (labeler.redact ? `;redact` : ``))
281+
.join(', '),
282+
},
283+
});
284+
});
285+
}
286+
}
287+
288+
/** Interface detailing what moderator service to use and how it should be used. */
289+
export interface ModerationService {
290+
/** Moderator service to use */
291+
did: At.DID;
292+
/** Whether it should apply takedowns made by this service. */
293+
redact?: boolean;
294+
}
295+
296+
/**
297+
* Retrieves AT Protocol PDS endpoint from the DID document, if available
298+
* @param doc DID document
299+
* @returns The PDS endpoint, if available
300+
*/
301+
export const getPdsEndpoint = (doc: DidDocument): string | undefined => {
302+
return getServiceEndpoint(doc, '#atproto_pds', 'AtprotoPersonalDataServer');
303+
};
304+
305+
/**
306+
* Retrieve a service endpoint from the DID document, if available
307+
* @param doc DID document
308+
* @param serviceId Service ID
309+
* @param serviceType Service type
310+
* @returns The requested service endpoint, if available
311+
*/
312+
export const getServiceEndpoint = (
313+
doc: DidDocument,
314+
serviceId: string,
315+
serviceType: string,
316+
): string | undefined => {
317+
const did = doc.id;
318+
319+
const didServiceId = did + serviceId;
320+
const found = doc.service?.find((service) => service.id === serviceId || service.id === didServiceId);
321+
322+
if (!found || found.type !== serviceType || typeof found.serviceEndpoint !== 'string') {
323+
return undefined;
324+
}
325+
326+
return validateUrl(found.serviceEndpoint);
327+
};
328+
329+
const validateUrl = (urlStr: string): string | undefined => {
330+
let url;
331+
try {
332+
url = new URL(urlStr);
333+
} catch {
334+
return undefined;
335+
}
336+
337+
const proto = url.protocol;
338+
339+
if (url.hostname && (proto === 'http:' || proto === 'https:')) {
340+
return urlStr;
341+
}
342+
};
343+
344+
/**
345+
* DID document
346+
*/
347+
export interface DidDocument {
348+
id: string;
349+
alsoKnownAs?: string[];
350+
verificationMethod?: Array<{
351+
id: string;
352+
type: string;
353+
controller: string;
354+
publicKeyMultibase?: string;
355+
}>;
356+
service?: Array<{
357+
id: string;
358+
type: string;
359+
serviceEndpoint: string | Record<string, unknown>;
360+
}>;
361+
}

0 commit comments

Comments
 (0)