@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
274 lines • 13.2 kB
JavaScript
import { Key, Keyset } from '@atproto/jwk';
import { oauthClientMetadataSchema, } from '@atproto/oauth-types';
import { assertAtprotoDid, } from '@atproto-labs/did-resolver';
import { HANDLE_INVALID } from '@atproto-labs/identity-resolver';
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory';
import { FALLBACK_ALG } from './constants.js';
import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js';
import { TokenRevokedError } from './errors/token-revoked-error.js';
import { createIdentityResolver, } from './identity-resolver.js';
import { OAuthAuthorizationServerMetadataResolver, } from './oauth-authorization-server-metadata-resolver.js';
import { OAuthCallbackError } from './oauth-callback-error.js';
import { negotiateClientAuthMethod } from './oauth-client-auth.js';
import { OAuthProtectedResourceMetadataResolver, } from './oauth-protected-resource-metadata-resolver.js';
import { OAuthResolver } from './oauth-resolver.js';
import { OAuthServerFactory } from './oauth-server-factory.js';
import { OAuthSession } from './oauth-session.js';
import { Runtime } from './runtime.js';
import { SessionGetter, isExpectedSessionError, } from './session-getter.js';
import { validateClientMetadata } from './validate-client-metadata.js';
export { Key, Keyset };
export class OAuthClient {
static async fetchMetadata({ clientId, fetch = globalThis.fetch, signal, }) {
signal?.throwIfAborted();
const request = new Request(clientId, {
redirect: 'error',
signal: signal,
});
const response = await fetch(request);
if (response.status !== 200) {
response.body?.cancel?.();
throw new TypeError(`Failed to fetch client metadata: ${response.status}`);
}
// https://www.ietf.org/archive/id/draft-ietf-oauth-client-id-metadata-document-00.html#section-4.1
const mime = response.headers.get('content-type')?.split(';')[0].trim();
if (mime !== 'application/json') {
response.body?.cancel?.();
throw new TypeError(`Invalid client metadata content type: ${mime}`);
}
const json = await response.json();
signal?.throwIfAborted();
return oauthClientMetadataSchema.parse(json);
}
constructor(options) {
const { stateStore, sessionStore, dpopNonceCache = new SimpleStoreMemory({ ttl: 60e3, max: 100 }), authorizationServerMetadataCache = new SimpleStoreMemory({
ttl: 60e3,
max: 100,
}), protectedResourceMetadataCache = new SimpleStoreMemory({
ttl: 60e3,
max: 100,
}), responseMode, clientMetadata, runtimeImplementation, keyset, } = options;
this.keyset = keyset
? keyset instanceof Keyset
? keyset
: new Keyset(keyset)
: undefined;
this.clientMetadata = validateClientMetadata(clientMetadata, this.keyset);
this.responseMode = responseMode;
this.runtime = new Runtime(runtimeImplementation);
this.fetch = options.fetch ?? globalThis.fetch;
this.oauthResolver = new OAuthResolver(createIdentityResolver(options), new OAuthProtectedResourceMetadataResolver(protectedResourceMetadataCache, this.fetch, { allowHttpResource: options.allowHttp }), new OAuthAuthorizationServerMetadataResolver(authorizationServerMetadataCache, this.fetch, { allowHttpIssuer: options.allowHttp }));
this.serverFactory = new OAuthServerFactory(this.clientMetadata, this.runtime, this.oauthResolver, this.fetch, this.keyset, dpopNonceCache);
this.stateStore = stateStore;
this.sessionGetter = new SessionGetter(sessionStore, this.serverFactory, this.runtime, options);
}
// Exposed as public API for convenience
get identityResolver() {
return this.oauthResolver.identityResolver;
}
get jwks() {
return this.keyset?.publicJwks ?? { keys: [] };
}
async authorize(input, { signal, ...options } = {}) {
const redirectUri = options?.redirect_uri ?? this.clientMetadata.redirect_uris[0];
if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
// The server will enforce this, but let's catch it early
throw new TypeError('Invalid redirect_uri');
}
const { identityInfo, metadata } = await this.oauthResolver.resolve(input, {
signal,
});
const pkce = await this.runtime.generatePKCE();
const dpopKey = await this.runtime.generateKey(metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG]);
const authMethod = negotiateClientAuthMethod(metadata, this.clientMetadata, this.keyset);
const state = await this.runtime.generateNonce();
await this.stateStore.set(state, {
iss: metadata.issuer,
dpopKey,
authMethod,
verifier: pkce.verifier,
appState: options?.state,
});
const parameters = {
...options,
client_id: this.clientMetadata.client_id,
redirect_uri: redirectUri,
code_challenge: pkce.challenge,
code_challenge_method: pkce.method,
state,
login_hint: identityInfo
? identityInfo.handle !== HANDLE_INVALID
? identityInfo.handle
: identityInfo.did
: undefined,
response_mode: this.responseMode,
response_type: 'code',
scope: options?.scope ?? this.clientMetadata.scope,
};
const authorizationUrl = new URL(metadata.authorization_endpoint);
// Since the user will be redirected to the authorization_endpoint url using
// a browser, we need to make sure that the url is valid.
if (authorizationUrl.protocol !== 'https:' &&
authorizationUrl.protocol !== 'http:') {
throw new TypeError(`Invalid authorization endpoint protocol: ${authorizationUrl.protocol}`);
}
if (metadata.pushed_authorization_request_endpoint) {
const server = await this.serverFactory.fromMetadata(metadata, authMethod, dpopKey);
const parResponse = await server.request('pushed_authorization_request', parameters);
authorizationUrl.searchParams.set('client_id', this.clientMetadata.client_id);
authorizationUrl.searchParams.set('request_uri', parResponse.request_uri);
return authorizationUrl;
}
else if (metadata.require_pushed_authorization_requests) {
throw new Error('Server requires pushed authorization requests (PAR) but no PAR endpoint is available');
}
else {
for (const [key, value] of Object.entries(parameters)) {
if (value)
authorizationUrl.searchParams.set(key, String(value));
}
// Length of the URL that will be sent to the server
const urlLength = authorizationUrl.pathname.length + authorizationUrl.search.length;
if (urlLength < 2048) {
return authorizationUrl;
}
else if (!metadata.pushed_authorization_request_endpoint) {
throw new Error('Login URL too long');
}
}
throw new Error('Server does not support pushed authorization requests (PAR)');
}
/**
* This method allows the client to proactively revoke the request_uri it
* created through PAR.
*/
async abortRequest(authorizeUrl) {
const requestUri = authorizeUrl.searchParams.get('request_uri');
if (!requestUri)
return;
// @NOTE This is not implemented here because, 1) the request server should
// invalidate the request_uri after some delay anyways, and 2) I am not sure
// that the revocation endpoint is even supposed to support this (and I
// don't want to spend the time checking now).
// @TODO investigate actual necessity & feasibility of this feature
}
async callback(params, options = {}) {
const responseJwt = params.get('response');
if (responseJwt != null) {
// https://openid.net/specs/oauth-v2-jarm.html
throw new OAuthCallbackError(params, 'JARM not supported');
}
const issuerParam = params.get('iss');
const stateParam = params.get('state');
const errorParam = params.get('error');
const codeParam = params.get('code');
if (!stateParam) {
throw new OAuthCallbackError(params, 'Missing "state" parameter');
}
const stateData = await this.stateStore.get(stateParam);
if (stateData) {
// Prevent any kind of replay
await this.stateStore.del(stateParam);
}
else {
throw new OAuthCallbackError(params, `Unknown authorization session "${stateParam}"`);
}
try {
if (errorParam != null) {
throw new OAuthCallbackError(params, undefined, stateData.appState);
}
if (!codeParam) {
throw new OAuthCallbackError(params, 'Missing "code" query param', stateData.appState);
}
const server = await this.serverFactory.fromIssuer(stateData.iss, stateData.authMethod, stateData.dpopKey);
if (issuerParam != null) {
if (!server.issuer) {
throw new OAuthCallbackError(params, 'Issuer not found in metadata', stateData.appState);
}
if (server.issuer !== issuerParam) {
throw new OAuthCallbackError(params, 'Issuer mismatch', stateData.appState);
}
}
else if (server.serverMetadata.authorization_response_iss_parameter_supported) {
throw new OAuthCallbackError(params, 'iss missing from the response', stateData.appState);
}
const tokenSet = await server.exchangeCode(codeParam, stateData.verifier, options?.redirect_uri ?? server.clientMetadata.redirect_uris[0]);
// We revoke any existing session first to avoid leaving orphaned sessions
// on the AS.
try {
await this.revoke(tokenSet.sub);
}
catch {
// No existing session, or failed to get it. This is fine.
}
try {
await this.sessionGetter.setStored(tokenSet.sub, {
dpopKey: stateData.dpopKey,
authMethod: server.authMethod,
tokenSet,
});
const session = this.createSession(server, tokenSet.sub);
return { session, state: stateData.appState ?? null };
}
catch (err) {
await server.revoke(tokenSet.refresh_token || tokenSet.access_token);
throw err;
}
}
catch (err) {
// Make sure, whatever the underlying error, that the appState is
// available in the calling code
throw OAuthCallbackError.from(err, params, stateData.appState);
}
}
/**
* Load a stored session. This will refresh the token only if needed (about to
* expire) by default.
*
* @see {@link SessionGetter.restore}
*/
async restore(sub, refresh = 'auto') {
// sub arg is lightly typed for convenience of library user
assertAtprotoDid(sub);
const { dpopKey, authMethod, tokenSet } = await this.sessionGetter.getSession(sub, refresh);
try {
const server = await this.serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey, {
noCache: refresh === true,
allowStale: refresh === false,
});
return this.createSession(server, sub);
}
catch (err) {
if (err instanceof AuthMethodUnsatisfiableError) {
await this.sessionGetter.delStored(sub, err);
}
throw err;
}
}
async revoke(sub) {
// sub arg is lightly typed for convenience of library user
assertAtprotoDid(sub);
const res = await this.sessionGetter.getSession(sub, false).catch((err) => {
if (isExpectedSessionError(err))
return null;
throw err;
});
if (!res)
return;
const { dpopKey, authMethod, tokenSet } = res;
// NOT using `;(await this.restore(sub, false)).signOut()` because we want
// the tokens to be deleted even if it was not possible to fetch the issuer
// data.
try {
const server = await this.serverFactory.fromIssuer(tokenSet.iss, authMethod, dpopKey);
await server.revoke(tokenSet.access_token);
}
finally {
await this.sessionGetter.delStored(sub, new TokenRevokedError(sub));
}
}
createSession(server, sub) {
return new OAuthSession(server, sub, this.sessionGetter, this.fetch);
}
}
//# sourceMappingURL=oauth-client.js.map