@atproto/oauth-client
Advanced tools
Comparing version 0.2.2 to 0.3.0
# @atproto/oauth-client | ||
## 0.3.0 | ||
### Minor Changes | ||
- [#2871](https://github.com/bluesky-social/atproto/pull/2871) [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Use `"auto"` instead of `undefined` to descibe the refresh mechanism to use in various methods. | ||
### Patch Changes | ||
- [#2874](https://github.com/bluesky-social/atproto/pull/2874) [`7f26b1765`](https://github.com/bluesky-social/atproto/commit/7f26b176526b9856a8f61faca6f065f0afd43abf) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Add `allowHttp` OAuthClient construction option to allow working with "http:" oauth providers (for development & testing purposes). | ||
- [#2871](https://github.com/bluesky-social/atproto/pull/2871) [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Perform issuer validation _before_ refreshing tokens. | ||
- [#2871](https://github.com/bluesky-social/atproto/pull/2871) [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Ensure token response is properly typed according to the atproto OAuth spec | ||
- [#2871](https://github.com/bluesky-social/atproto/pull/2871) [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Use fetch()'s "cache" option instead of headers to force caching behavior | ||
- [#2871](https://github.com/bluesky-social/atproto/pull/2871) [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Do not use cache when checking sub authority | ||
- [#2871](https://github.com/bluesky-social/atproto/pull/2871) [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Allow all oauth request parameters to be used as authorize() options | ||
- Updated dependencies [[`7f26b1765`](https://github.com/bluesky-social/atproto/commit/7f26b176526b9856a8f61faca6f065f0afd43abf), [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2), [`7f26b1765`](https://github.com/bluesky-social/atproto/commit/7f26b176526b9856a8f61faca6f065f0afd43abf), [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2), [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2), [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2), [`7f26b1765`](https://github.com/bluesky-social/atproto/commit/7f26b176526b9856a8f61faca6f065f0afd43abf), [`9d40ccbb6`](https://github.com/bluesky-social/atproto/commit/9d40ccbb69103fae9aae7e3cec31e9b3116f3ba2), [`7f26b1765`](https://github.com/bluesky-social/atproto/commit/7f26b176526b9856a8f61faca6f065f0afd43abf)]: | ||
- @atproto/oauth-types@0.2.0 | ||
- @atproto-labs/did-resolver@0.1.5 | ||
- @atproto-labs/handle-resolver@0.1.4 | ||
- @atproto/did@0.1.3 | ||
- @atproto-labs/identity-resolver@0.1.5 | ||
## 0.2.2 | ||
@@ -4,0 +31,0 @@ |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.dpopFetchWrapper = void 0; | ||
exports.dpopFetchWrapper = dpopFetchWrapper; | ||
const fetch_1 = require("@atproto-labs/fetch"); | ||
@@ -78,3 +78,2 @@ const base64_1 = require("multiformats/bases/base64"); | ||
} | ||
exports.dpopFetchWrapper = dpopFetchWrapper; | ||
async function buildProof(key, alg, iss, htm, htu, nonce, ath) { | ||
@@ -81,0 +80,0 @@ if (!key.bareJwk) { |
@@ -6,2 +6,5 @@ import { Fetch } from '@atproto-labs/fetch'; | ||
export type AuthorizationServerMetadataCache = SimpleStore<string, OAuthAuthorizationServerMetadata>; | ||
export type OAuthAuthorizationServerMetadataResolverConfig = { | ||
allowHttpIssuer?: boolean; | ||
}; | ||
/** | ||
@@ -12,6 +15,7 @@ * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} | ||
private readonly fetch; | ||
constructor(cache: AuthorizationServerMetadataCache, fetch?: Fetch); | ||
get(issuer: string, options?: GetCachedOptions): Promise<OAuthAuthorizationServerMetadata>; | ||
private readonly allowHttpIssuer; | ||
constructor(cache: AuthorizationServerMetadataCache, fetch?: Fetch, config?: OAuthAuthorizationServerMetadataResolverConfig); | ||
get(input: string, options?: GetCachedOptions): Promise<OAuthAuthorizationServerMetadata>; | ||
private fetchMetadata; | ||
} | ||
//# sourceMappingURL=oauth-authorization-server-metadata-resolver.d.ts.map |
@@ -7,3 +7,3 @@ "use strict"; | ||
const oauth_types_1 = require("@atproto/oauth-types"); | ||
const util_1 = require("./util"); | ||
const util_js_1 = require("./util.js"); | ||
/** | ||
@@ -13,3 +13,3 @@ * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} | ||
class OAuthAuthorizationServerMetadataResolver extends simple_store_1.CachedGetter { | ||
constructor(cache, fetch) { | ||
constructor(cache, fetch, config) { | ||
super(async (issuer, options) => this.fetchMetadata(issuer, options), cache); | ||
@@ -22,15 +22,24 @@ Object.defineProperty(this, "fetch", { | ||
}); | ||
Object.defineProperty(this, "allowHttpIssuer", { | ||
enumerable: true, | ||
configurable: true, | ||
writable: true, | ||
value: void 0 | ||
}); | ||
this.fetch = (0, fetch_1.bindFetch)(fetch); | ||
this.allowHttpIssuer = config?.allowHttpIssuer === true; | ||
} | ||
async get(issuer, options) { | ||
return super.get(oauth_types_1.oauthIssuerIdentifierSchema.parse(issuer), options); | ||
async get(input, options) { | ||
const issuer = oauth_types_1.oauthIssuerIdentifierSchema.parse(input); | ||
if (!this.allowHttpIssuer && issuer.startsWith('http:')) { | ||
throw new TypeError('Unsecure issuer URL protocol only allowed in development and test environments'); | ||
} | ||
return super.get(issuer, options); | ||
} | ||
async fetchMetadata(issuer, options) { | ||
const headers = new Headers([['accept', 'application/json']]); | ||
if (options?.noCache) | ||
headers.set('cache-control', 'no-cache'); | ||
const url = new URL(`/.well-known/oauth-authorization-server`, issuer); | ||
const request = new Request(url, { | ||
headers: { accept: 'application/json' }, | ||
cache: options?.noCache ? 'no-cache' : undefined, | ||
signal: options?.signal, | ||
headers, | ||
redirect: 'manual', // response must be 200 OK | ||
@@ -44,3 +53,3 @@ }); | ||
} | ||
if ((0, util_1.contentMime)(response.headers) !== 'application/json') { | ||
if ((0, util_js_1.contentMime)(response.headers) !== 'application/json') { | ||
await (0, fetch_1.cancelBody)(response, 'log'); | ||
@@ -47,0 +56,0 @@ throw await fetch_1.FetchResponseError.from(response, `Unexpected content type for "${url}"`, undefined, { cause: request }); |
@@ -1,2 +0,2 @@ | ||
import { DidCache } from '@atproto-labs/did-resolver'; | ||
import { AtprotoDid, DidCache } from '@atproto-labs/did-resolver'; | ||
import { Fetch } from '@atproto-labs/fetch'; | ||
@@ -24,2 +24,17 @@ import { HandleCache, HandleResolver } from '@atproto-labs/handle-resolver'; | ||
keyset?: Keyset | Iterable<Key | undefined | null | false>; | ||
/** | ||
* Determines if the client will allow communicating with the OAuth Servers | ||
* (Authorization & Resource), or to retrieve "did:web" documents, over | ||
* unsafe HTTP connections. It is recommended to set this to `true` only for | ||
* development purposes. | ||
* | ||
* @note This does not affect the identity resolution mechanism, which will | ||
* allow HTTP connections to the PLC Directory (if the provided directory url | ||
* is "http:" based). | ||
* @default false | ||
* @see {@link OAuthProtectedResourceMetadataResolver.allowHttpResource} | ||
* @see {@link OAuthAuthorizationServerMetadataResolver.allowHttpIssuer} | ||
* @see {@link DidResolverCommonOptions.allowHttp} | ||
*/ | ||
allowHttp?: boolean; | ||
stateStore: StateStore; | ||
@@ -46,6 +61,6 @@ sessionStore: SessionStore; | ||
redirect_uris: [string, ...string[]]; | ||
response_types: ["none" | "code" | "token" | "code id_token token" | "code id_token" | "code token" | "id_token token" | "id_token", ...("none" | "code" | "token" | "code id_token token" | "code id_token" | "code token" | "id_token token" | "id_token")[]]; | ||
response_types: ["code" | "none" | "token" | "code id_token token" | "code id_token" | "code token" | "id_token token" | "id_token", ...("code" | "none" | "token" | "code id_token token" | "code id_token" | "code token" | "id_token token" | "id_token")[]]; | ||
grant_types: ["authorization_code" | "implicit" | "refresh_token" | "password" | "client_credentials" | "urn:ietf:params:oauth:grant-type:jwt-bearer" | "urn:ietf:params:oauth:grant-type:saml2-bearer", ...("authorization_code" | "implicit" | "refresh_token" | "password" | "client_credentials" | "urn:ietf:params:oauth:grant-type:jwt-bearer" | "urn:ietf:params:oauth:grant-type:saml2-bearer")[]]; | ||
scope?: string | undefined; | ||
token_endpoint_auth_method?: "none" | "client_secret_basic" | "client_secret_jwt" | "client_secret_post" | "private_key_jwt" | "self_signed_tls_client_auth" | "tls_client_auth" | undefined; | ||
token_endpoint_auth_method?: "client_secret_basic" | "client_secret_jwt" | "client_secret_post" | "none" | "private_key_jwt" | "self_signed_tls_client_auth" | "tls_client_auth" | undefined; | ||
token_endpoint_auth_signing_alg?: string | undefined; | ||
@@ -67,3 +82,3 @@ userinfo_signed_response_alg?: string | undefined; | ||
x5t?: string | undefined; | ||
'x5t#S256'?: string | undefined; | ||
"x5t#S256"?: string | undefined; | ||
x5u?: string | undefined; | ||
@@ -97,3 +112,3 @@ d?: string | undefined; | ||
x5t?: string | undefined; | ||
'x5t#S256'?: string | undefined; | ||
"x5t#S256"?: string | undefined; | ||
x5u?: string | undefined; | ||
@@ -113,3 +128,3 @@ d?: string | undefined; | ||
x5t?: string | undefined; | ||
'x5t#S256'?: string | undefined; | ||
"x5t#S256"?: string | undefined; | ||
x5u?: string | undefined; | ||
@@ -128,3 +143,3 @@ d?: string | undefined; | ||
x5t?: string | undefined; | ||
'x5t#S256'?: string | undefined; | ||
"x5t#S256"?: string | undefined; | ||
x5u?: string | undefined; | ||
@@ -142,3 +157,3 @@ d?: string | undefined; | ||
x5t?: string | undefined; | ||
'x5t#S256'?: string | undefined; | ||
"x5t#S256"?: string | undefined; | ||
x5u?: string | undefined; | ||
@@ -154,3 +169,3 @@ } | { | ||
x5t?: string | undefined; | ||
'x5t#S256'?: string | undefined; | ||
"x5t#S256"?: string | undefined; | ||
x5u?: string | undefined; | ||
@@ -186,5 +201,5 @@ })[]; | ||
readonly serverFactory: OAuthServerFactory; | ||
readonly sessionGetter: SessionGetter; | ||
readonly stateStore: StateStore; | ||
constructor({ fetch, stateStore, sessionStore, didCache, dpopNonceCache, handleCache, authorizationServerMetadataCache, protectedResourceMetadataCache, responseMode, clientMetadata, handleResolver, plcDirectoryUrl, runtimeImplementation, keyset, }: OAuthClientOptions); | ||
protected readonly sessionGetter: SessionGetter; | ||
protected readonly stateStore: StateStore; | ||
constructor({ fetch, allowHttp, stateStore, sessionStore, didCache, dpopNonceCache, handleCache, authorizationServerMetadataCache, protectedResourceMetadataCache, responseMode, clientMetadata, handleResolver, plcDirectoryUrl, runtimeImplementation, keyset, }: OAuthClientOptions); | ||
get identityResolver(): IdentityResolver; | ||
@@ -291,3 +306,3 @@ get didResolver(): import("@atproto-labs/did-resolver").DidResolver<import("@atproto-labs/did-resolver").AtprotoIdentityDidMethods>; | ||
}; | ||
authorize(input: string, options?: AuthorizeOptions): Promise<URL>; | ||
authorize(input: string, { signal, ...options }?: AuthorizeOptions): Promise<URL>; | ||
/** | ||
@@ -308,6 +323,6 @@ * This method allows the client to proactively revoke the request_uri it | ||
*/ | ||
restore(sub: string, refresh?: boolean): Promise<OAuthSession>; | ||
restore(sub: string, refresh?: boolean | 'auto'): Promise<OAuthSession>; | ||
revoke(sub: string): Promise<void>; | ||
protected createSession(server: OAuthServerAgent, sub: string): OAuthSession; | ||
protected createSession(server: OAuthServerAgent, sub: AtprotoDid): OAuthSession; | ||
} | ||
//# sourceMappingURL=oauth-client.d.ts.map |
@@ -44,3 +44,3 @@ "use strict"; | ||
} | ||
constructor({ fetch = globalThis.fetch, stateStore, sessionStore, didCache = undefined, dpopNonceCache = new simple_store_memory_1.SimpleStoreMemory({ ttl: 60e3, max: 100 }), handleCache = undefined, authorizationServerMetadataCache = new simple_store_memory_1.SimpleStoreMemory({ | ||
constructor({ fetch = globalThis.fetch, allowHttp = false, stateStore, sessionStore, didCache = undefined, dpopNonceCache = new simple_store_memory_1.SimpleStoreMemory({ ttl: 60e3, max: 100 }), handleCache = undefined, authorizationServerMetadataCache = new simple_store_memory_1.SimpleStoreMemory({ | ||
ttl: 60e3, | ||
@@ -119,3 +119,3 @@ max: 100, | ||
this.fetch = fetch; | ||
this.oauthResolver = new oauth_resolver_js_1.OAuthResolver(new identity_resolver_1.IdentityResolver(new did_resolver_1.DidResolverCached(new did_resolver_1.DidResolverCommon({ fetch, plcDirectoryUrl }), didCache), new handle_resolver_1.CachedHandleResolver(handle_resolver_1.AppViewHandleResolver.from(handleResolver, { fetch }), handleCache)), new oauth_protected_resource_metadata_resolver_js_1.OAuthProtectedResourceMetadataResolver(protectedResourceMetadataCache, fetch), new oauth_authorization_server_metadata_resolver_js_1.OAuthAuthorizationServerMetadataResolver(authorizationServerMetadataCache, fetch)); | ||
this.oauthResolver = new oauth_resolver_js_1.OAuthResolver(new identity_resolver_1.IdentityResolver(new did_resolver_1.DidResolverCached(new did_resolver_1.DidResolverCommon({ fetch, plcDirectoryUrl, allowHttp }), didCache), new handle_resolver_1.CachedHandleResolver(handle_resolver_1.AppViewHandleResolver.from(handleResolver, { fetch }), handleCache)), new oauth_protected_resource_metadata_resolver_js_1.OAuthProtectedResourceMetadataResolver(protectedResourceMetadataCache, fetch, { allowHttpResource: allowHttp }), new oauth_authorization_server_metadata_resolver_js_1.OAuthAuthorizationServerMetadataResolver(authorizationServerMetadataCache, fetch, { allowHttpIssuer: allowHttp })); | ||
this.serverFactory = new oauth_server_factory_js_1.OAuthServerFactory(this.clientMetadata, this.runtime, this.oauthResolver, this.fetch, this.keyset, dpopNonceCache); | ||
@@ -148,3 +148,3 @@ this.sessionGetter = new session_getter_js_1.SessionGetter(sessionStore, this.serverFactory, this.runtime); | ||
} | ||
async authorize(input, options) { | ||
async authorize(input, { signal, ...options } = {}) { | ||
const redirectUri = options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]; | ||
@@ -155,3 +155,5 @@ if (!this.clientMetadata.redirect_uris.includes(redirectUri)) { | ||
} | ||
const { identity, metadata } = await this.oauthResolver.resolve(input, options); | ||
const { identity, metadata } = await this.oauthResolver.resolve(input, { | ||
signal, | ||
}); | ||
const pkce = await this.runtime.generatePKCE(); | ||
@@ -167,2 +169,3 @@ const dpopKey = await this.runtime.generateKey(metadata.dpop_signing_alg_values_supported || [constants_js_1.FALLBACK_ALG]); | ||
const parameters = { | ||
...options, | ||
client_id: this.clientMetadata.client_id, | ||
@@ -178,6 +181,3 @@ redirect_uri: redirectUri, | ||
response_type: 'code', | ||
display: options?.display, | ||
prompt: options?.prompt, | ||
scope: options?.scope ?? this.clientMetadata.scope, | ||
ui_locales: options?.ui_locales, | ||
}; | ||
@@ -256,6 +256,6 @@ if (metadata.pushed_authorization_request_endpoint) { | ||
if (issuerParam != null) { | ||
if (!server.serverMetadata.issuer) { | ||
if (!server.issuer) { | ||
throw new oauth_callback_error_js_1.OAuthCallbackError(params, 'Issuer not found in metadata', stateData.appState); | ||
} | ||
if (server.serverMetadata.issuer !== issuerParam) { | ||
if (server.issuer !== issuerParam) { | ||
throw new oauth_callback_error_js_1.OAuthCallbackError(params, 'Issuer mismatch', stateData.appState); | ||
@@ -277,3 +277,3 @@ } | ||
catch (err) { | ||
await server.revoke(tokenSet.access_token); | ||
await server.revoke(tokenSet.refresh_token || tokenSet.access_token); | ||
throw err; | ||
@@ -294,4 +294,9 @@ } | ||
*/ | ||
async restore(sub, refresh) { | ||
const { dpopKey, tokenSet } = await this.sessionGetter.getSession(sub, refresh); | ||
async restore(sub, refresh = 'auto') { | ||
// sub arg is lightly typed for convenience of library user | ||
(0, did_resolver_1.assertAtprotoDid)(sub); | ||
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, { | ||
noCache: refresh === true, | ||
allowStale: refresh === false, | ||
}); | ||
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey, { | ||
@@ -304,3 +309,7 @@ noCache: refresh === true, | ||
async revoke(sub) { | ||
const { dpopKey, tokenSet } = await this.sessionGetter.getSession(sub, false); | ||
// sub arg is lightly typed for convenience of library user | ||
(0, did_resolver_1.assertAtprotoDid)(sub); | ||
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, { | ||
allowStale: true, | ||
}); | ||
// NOT using `;(await this.restore(sub, false)).signOut()` because we want | ||
@@ -307,0 +316,0 @@ // the tokens to be deleted even if it was not possible to fetch the issuer |
@@ -6,2 +6,5 @@ import { Fetch } from '@atproto-labs/fetch'; | ||
export type ProtectedResourceMetadataCache = SimpleStore<string, OAuthProtectedResourceMetadata>; | ||
export type OAuthProtectedResourceMetadataResolverConfig = { | ||
allowHttpResource?: boolean; | ||
}; | ||
/** | ||
@@ -12,3 +15,4 @@ * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05} | ||
private readonly fetch; | ||
constructor(cache: ProtectedResourceMetadataCache, fetch?: Fetch); | ||
private readonly allowHttpResource; | ||
constructor(cache: ProtectedResourceMetadataCache, fetch?: Fetch, config?: OAuthProtectedResourceMetadataResolverConfig); | ||
get(resource: string | URL, options?: GetCachedOptions): Promise<OAuthProtectedResourceMetadata>; | ||
@@ -15,0 +19,0 @@ private fetchMetadata; |
@@ -7,3 +7,3 @@ "use strict"; | ||
const oauth_types_1 = require("@atproto/oauth-types"); | ||
const util_1 = require("./util"); | ||
const util_js_1 = require("./util.js"); | ||
/** | ||
@@ -13,3 +13,3 @@ * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05} | ||
class OAuthProtectedResourceMetadataResolver extends simple_store_1.CachedGetter { | ||
constructor(cache, fetch = globalThis.fetch) { | ||
constructor(cache, fetch = globalThis.fetch, config) { | ||
super(async (origin, options) => this.fetchMetadata(origin, options), cache); | ||
@@ -22,20 +22,27 @@ Object.defineProperty(this, "fetch", { | ||
}); | ||
Object.defineProperty(this, "allowHttpResource", { | ||
enumerable: true, | ||
configurable: true, | ||
writable: true, | ||
value: void 0 | ||
}); | ||
this.fetch = (0, fetch_1.bindFetch)(fetch); | ||
this.allowHttpResource = config?.allowHttpResource === true; | ||
} | ||
async get(resource, options) { | ||
const { protocol, origin } = new URL(resource); | ||
if (protocol === 'https:' || | ||
(protocol === 'http:' && oauth_types_1.ALLOW_UNSECURE_ORIGINS)) { | ||
return super.get(origin, options); | ||
if (protocol !== 'https:' && protocol !== 'http:') { | ||
throw new TypeError(`Invalid protected resource metadata URL protocol: ${protocol}`); | ||
} | ||
throw new TypeError(`Forbidden resource sercure protocol "${protocol}"`); | ||
if (protocol === 'http:' && !this.allowHttpResource) { | ||
throw new TypeError(`Unsecure resource metadata URL (${protocol}) only allowed in development and test environments`); | ||
} | ||
return super.get(origin, options); | ||
} | ||
async fetchMetadata(origin, options) { | ||
const headers = new Headers([['accept', 'application/json']]); | ||
if (options?.noCache) | ||
headers.set('cache-control', 'no-cache'); | ||
const url = new URL(`/.well-known/oauth-protected-resource`, origin); | ||
const request = new Request(url, { | ||
signal: options?.signal, | ||
headers, | ||
headers: { accept: 'application/json' }, | ||
cache: options?.noCache ? 'no-cache' : undefined, | ||
redirect: 'manual', // response must be 200 OK | ||
@@ -49,3 +56,3 @@ }); | ||
} | ||
if ((0, util_1.contentMime)(response.headers) !== 'application/json') { | ||
if ((0, util_js_1.contentMime)(response.headers) !== 'application/json') { | ||
await (0, fetch_1.cancelBody)(response, 'log'); | ||
@@ -52,0 +59,0 @@ throw await fetch_1.FetchResponseError.from(response, `Unexpected content type for "${url}"`, undefined, { cause: request }); |
@@ -33,3 +33,3 @@ import { IdentityResolver, ResolvedIdentity, ResolveIdentityOptions } from '@atproto-labs/identity-resolver'; | ||
getResourceServerMetadata(pdsUrl: string | URL, options?: GetCachedOptions): Promise<{ | ||
issuer: string; | ||
issuer: `http://${string}` | `https://${string}`; | ||
authorization_endpoint: string; | ||
@@ -36,0 +36,0 @@ token_endpoint: string; |
import { Fetch, Json } from '@atproto-labs/fetch'; | ||
import { SimpleStore } from '@atproto-labs/simple-store'; | ||
import { AtprotoDid } from '@atproto/did'; | ||
import { Key, Keyset } from '@atproto/jwk'; | ||
import { OAuthAuthorizationServerMetadata, OAuthClientCredentials, OAuthEndpointName, OAuthParResponse, OAuthTokenResponse, OAuthTokenType } from '@atproto/oauth-types'; | ||
import { OAuthAuthorizationRequestPar, OAuthAuthorizationServerMetadata, OAuthClientCredentials, OAuthEndpointName, OAuthParResponse, OAuthTokenRequest } from '@atproto/oauth-types'; | ||
import { AtprotoScope, AtprotoTokenResponse } from './atproto-token-response.js'; | ||
import { OAuthResolver } from './oauth-resolver.js'; | ||
@@ -10,8 +12,8 @@ import { Runtime } from './runtime.js'; | ||
iss: string; | ||
sub: string; | ||
sub: AtprotoDid; | ||
aud: string; | ||
scope: string; | ||
scope: AtprotoScope; | ||
refresh_token?: string; | ||
access_token: string; | ||
token_type: OAuthTokenType; | ||
token_type: 'DPoP'; | ||
/** ISO Date */ | ||
@@ -28,7 +30,8 @@ expires_at?: string; | ||
readonly runtime: Runtime; | ||
readonly keyset?: Keyset<Key> | undefined; | ||
readonly keyset?: Keyset | undefined; | ||
protected dpopFetch: Fetch<unknown>; | ||
constructor(dpopKey: Key, serverMetadata: OAuthAuthorizationServerMetadata, clientMetadata: ClientMetadata, dpopNonces: DpopNonceCache, oauthResolver: OAuthResolver, runtime: Runtime, keyset?: Keyset<Key> | undefined, fetch?: Fetch); | ||
constructor(dpopKey: Key, serverMetadata: OAuthAuthorizationServerMetadata, clientMetadata: ClientMetadata, dpopNonces: DpopNonceCache, oauthResolver: OAuthResolver, runtime: Runtime, keyset?: Keyset | undefined, fetch?: Fetch); | ||
get issuer(): `http://${string}` | `https://${string}`; | ||
revoke(token: string): Promise<void>; | ||
exchangeCode(code: string, verifier?: string): Promise<TokenSet>; | ||
exchangeCode(code: string, codeVerifier?: string): Promise<TokenSet>; | ||
refresh(tokenSet: TokenSet): Promise<TokenSet>; | ||
@@ -42,7 +45,7 @@ /** | ||
* able to use the "sub" (DID) as being the actual user's identifier. | ||
* | ||
* @returns The user's PDS URL (the resource server for the user) | ||
*/ | ||
private processTokenResponse; | ||
request(endpoint: 'token', payload: Record<string, unknown>): Promise<OAuthTokenResponse>; | ||
request(endpoint: 'pushed_authorization_request', payload: Record<string, unknown>): Promise<OAuthParResponse>; | ||
request(endpoint: OAuthEndpointName, payload: Record<string, unknown>): Promise<Json>; | ||
protected verifyIssuer(sub: AtprotoDid): Promise<string>; | ||
request<Endpoint extends OAuthEndpointName>(endpoint: Endpoint, payload: Endpoint extends 'token' ? OAuthTokenRequest : Endpoint extends 'pushed_authorization_request' ? OAuthAuthorizationRequestPar : Record<string, unknown>): Promise<Endpoint extends 'token' ? AtprotoTokenResponse : Endpoint extends 'pushed_authorization_request' ? OAuthParResponse : Json>; | ||
buildClientAuth(endpoint: OAuthEndpointName): Promise<{ | ||
@@ -49,0 +52,0 @@ headers?: Record<string, string>; |
@@ -5,3 +5,3 @@ "use strict"; | ||
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected."); | ||
var dispose; | ||
var dispose, inner; | ||
if (async) { | ||
@@ -14,4 +14,6 @@ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined."); | ||
dispose = value[Symbol.dispose]; | ||
if (async) inner = dispose; | ||
} | ||
if (typeof dispose !== "function") throw new TypeError("Object not disposable."); | ||
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } }; | ||
env.stack.push({ value: value, dispose: dispose, async: async }); | ||
@@ -30,8 +32,12 @@ } | ||
} | ||
var r, s = 0; | ||
function next() { | ||
while (env.stack.length) { | ||
var rec = env.stack.pop(); | ||
while (r = env.stack.pop()) { | ||
try { | ||
var result = rec.dispose && rec.dispose.call(rec.value); | ||
if (rec.async) return Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); | ||
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next); | ||
if (r.dispose) { | ||
var result = r.dispose.call(r.value); | ||
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); | ||
} | ||
else s |= 1; | ||
} | ||
@@ -42,2 +48,3 @@ catch (e) { | ||
} | ||
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve(); | ||
if (env.hasError) throw env.error; | ||
@@ -55,2 +62,3 @@ } | ||
const oauth_types_1 = require("@atproto/oauth-types"); | ||
const atproto_token_response_js_1 = require("./atproto-token-response.js"); | ||
const constants_js_1 = require("./constants.js"); | ||
@@ -121,2 +129,5 @@ const token_refresh_error_js_1 = require("./errors/token-refresh-error.js"); | ||
} | ||
get issuer() { | ||
return this.serverMetadata.issuer; | ||
} | ||
async revoke(token) { | ||
@@ -130,3 +141,4 @@ try { | ||
} | ||
async exchangeCode(code, verifier) { | ||
async exchangeCode(code, codeVerifier) { | ||
const now = Date.now(); | ||
const tokenResponse = await this.request('token', { | ||
@@ -136,6 +148,22 @@ grant_type: 'authorization_code', | ||
code, | ||
code_verifier: verifier, | ||
code_verifier: codeVerifier, | ||
}); | ||
try { | ||
return this.processTokenResponse(tokenResponse); | ||
// /!\ IMPORTANT /!\ | ||
// | ||
// The tokenResponse MUST always be valid before the "sub" it contains | ||
// can be trusted (see Atproto's OAuth spec for details). | ||
const aud = await this.verifyIssuer(tokenResponse.sub); | ||
return { | ||
aud, | ||
sub: tokenResponse.sub, | ||
iss: this.issuer, | ||
scope: tokenResponse.scope, | ||
refresh_token: tokenResponse.refresh_token, | ||
access_token: tokenResponse.access_token, | ||
token_type: tokenResponse.token_type, | ||
expires_at: typeof tokenResponse.expires_in === 'number' | ||
? new Date(now + tokenResponse.expires_in * 1000).toISOString() | ||
: undefined, | ||
}; | ||
} | ||
@@ -151,2 +179,12 @@ catch (err) { | ||
} | ||
// /!\ IMPORTANT /!\ | ||
// | ||
// The "sub" MUST be a DID, whose issuer authority is indeed the server we | ||
// are trying to obtain credentials from. Note that we are doing this | ||
// *before* we actually try to refresh the token: | ||
// 1) To avoid unnecessary refresh | ||
// 2) So that the refresh is the last async operation, ensuring as few | ||
// async operations happen before the result gets a chance to be stored. | ||
const aud = await this.verifyIssuer(tokenSet.sub); | ||
const now = Date.now(); | ||
const tokenResponse = await this.request('token', { | ||
@@ -156,15 +194,14 @@ grant_type: 'refresh_token', | ||
}); | ||
try { | ||
if (tokenSet.sub !== tokenResponse.sub) { | ||
throw new token_refresh_error_js_1.TokenRefreshError(tokenSet.sub, `Unexpected "sub" in token response (${tokenResponse.sub})`); | ||
} | ||
if (tokenSet.iss !== this.serverMetadata.issuer) { | ||
throw new token_refresh_error_js_1.TokenRefreshError(tokenSet.sub, 'Issuer mismatch'); | ||
} | ||
return this.processTokenResponse(tokenResponse); | ||
} | ||
catch (err) { | ||
await this.revoke(tokenResponse.access_token); | ||
throw err; | ||
} | ||
return { | ||
aud, | ||
sub: tokenSet.sub, | ||
iss: this.issuer, | ||
scope: tokenResponse.scope, | ||
refresh_token: tokenResponse.refresh_token, | ||
access_token: tokenResponse.access_token, | ||
token_type: tokenResponse.token_type, | ||
expires_at: typeof tokenResponse.expires_in === 'number' | ||
? new Date(now + tokenResponse.expires_in * 1000).toISOString() | ||
: undefined, | ||
}; | ||
} | ||
@@ -178,22 +215,15 @@ /** | ||
* able to use the "sub" (DID) as being the actual user's identifier. | ||
* | ||
* @returns The user's PDS URL (the resource server for the user) | ||
*/ | ||
async processTokenResponse(tokenResponse) { | ||
async verifyIssuer(sub) { | ||
const env_1 = { stack: [], error: void 0, hasError: false }; | ||
try { | ||
const { sub } = tokenResponse; | ||
if (!sub || typeof sub !== 'string') { | ||
throw new TypeError(`Unexpected ${typeof sub} "sub" in token response`); | ||
} | ||
// Using an array to check for the presence of the "atproto" scope (we don't | ||
// want atproto to be a substring of another scope) | ||
const scopes = tokenResponse.scope?.split(' '); | ||
if (!scopes?.includes('atproto')) { | ||
throw new TypeError('Missing "atproto" scope in token response'); | ||
} | ||
// @TODO (?) make timeout configurable | ||
const signal = __addDisposableResource(env_1, (0, util_js_1.timeoutSignal)(10e3), false); | ||
const resolved = await this.oauthResolver.resolveFromIdentity(sub, { | ||
noCache: true, | ||
allowStale: false, | ||
signal, | ||
}); | ||
if (this.serverMetadata.issuer !== resolved.metadata.issuer) { | ||
if (this.issuer !== resolved.metadata.issuer) { | ||
// Best case scenario; the user switched PDS. Worst case scenario; a bad | ||
@@ -204,14 +234,3 @@ // actor is trying to impersonate a user. In any case, we must not allow | ||
} | ||
return { | ||
aud: resolved.identity.pds.href, | ||
iss: resolved.metadata.issuer, | ||
sub, | ||
scope: tokenResponse.scope, | ||
refresh_token: tokenResponse.refresh_token, | ||
access_token: tokenResponse.access_token, | ||
token_type: tokenResponse.token_type ?? 'Bearer', | ||
expires_at: typeof tokenResponse.expires_in === 'number' | ||
? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString() | ||
: undefined, | ||
}; | ||
return resolved.identity.pds.href; | ||
} | ||
@@ -239,3 +258,3 @@ catch (e_1) { | ||
case 'token': | ||
return oauth_types_1.oauthTokenResponseSchema.parse(json); | ||
return atproto_token_response_js_1.atprotoTokenResponseSchema.parse(json); | ||
case 'pushed_authorization_request': | ||
@@ -242,0 +261,0 @@ return oauth_types_1.oauthParResponseSchema.parse(json); |
import { Fetch } from '@atproto-labs/fetch'; | ||
import { AtprotoDid } from '@atproto/did'; | ||
import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types'; | ||
import { AtprotoScope } from './atproto-token-response.js'; | ||
import { OAuthServerAgent, TokenSet } from './oauth-server-agent.js'; | ||
@@ -8,20 +10,23 @@ import { SessionGetter } from './session-getter.js'; | ||
expired?: boolean; | ||
scope?: string; | ||
scope: AtprotoScope; | ||
iss: string; | ||
aud: string; | ||
sub: string; | ||
sub: AtprotoDid; | ||
}; | ||
export declare class OAuthSession { | ||
readonly server: OAuthServerAgent; | ||
readonly sub: string; | ||
readonly sub: AtprotoDid; | ||
private readonly sessionGetter; | ||
protected dpopFetch: Fetch<unknown>; | ||
constructor(server: OAuthServerAgent, sub: string, sessionGetter: SessionGetter, fetch?: Fetch); | ||
get did(): `did:${string}:${string}`; | ||
constructor(server: OAuthServerAgent, sub: AtprotoDid, sessionGetter: SessionGetter, fetch?: Fetch); | ||
get did(): AtprotoDid; | ||
get serverMetadata(): Readonly<OAuthAuthorizationServerMetadata>; | ||
/** | ||
* @param refresh See {@link SessionGetter.getSession} | ||
* @param refresh When `true`, the credentials will be refreshed even if they | ||
* are not expired. When `false`, the credentials will not be refreshed even | ||
* if they are expired. When `undefined`, the credentials will be refreshed | ||
* if, and only if, they are (about to be) expired. Defaults to `undefined`. | ||
*/ | ||
getTokenSet(refresh?: boolean): Promise<TokenSet>; | ||
getTokenInfo(refresh?: boolean): Promise<TokenInfo>; | ||
protected getTokenSet(refresh: boolean | 'auto'): Promise<TokenSet>; | ||
getTokenInfo(refresh?: boolean | 'auto'): Promise<TokenInfo>; | ||
signOut(): Promise<void>; | ||
@@ -28,0 +33,0 @@ fetchHandler(pathname: string, init?: RequestInit): Promise<Response>; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.OAuthSession = void 0; | ||
const did_1 = require("@atproto/did"); | ||
const fetch_1 = require("@atproto-labs/fetch"); | ||
@@ -47,3 +46,3 @@ const token_invalid_error_js_1 = require("./errors/token-invalid-error.js"); | ||
get did() { | ||
return (0, did_1.asDid)(this.sub); | ||
return this.sub; | ||
} | ||
@@ -54,9 +53,15 @@ get serverMetadata() { | ||
/** | ||
* @param refresh See {@link SessionGetter.getSession} | ||
* @param refresh When `true`, the credentials will be refreshed even if they | ||
* are not expired. When `false`, the credentials will not be refreshed even | ||
* if they are expired. When `undefined`, the credentials will be refreshed | ||
* if, and only if, they are (about to be) expired. Defaults to `undefined`. | ||
*/ | ||
async getTokenSet(refresh) { | ||
const { tokenSet } = await this.sessionGetter.getSession(this.sub, refresh); | ||
const { tokenSet } = await this.sessionGetter.get(this.sub, { | ||
noCache: refresh === true, | ||
allowStale: refresh === false, | ||
}); | ||
return tokenSet; | ||
} | ||
async getTokenInfo(refresh) { | ||
async getTokenInfo(refresh = 'auto') { | ||
const tokenSet = await this.getTokenSet(refresh); | ||
@@ -79,3 +84,3 @@ const expiresAt = tokenSet.expires_at == null ? undefined : new Date(tokenSet.expires_at); | ||
try { | ||
const { tokenSet } = await this.sessionGetter.getSession(this.sub, false); | ||
const tokenSet = await this.getTokenSet(false); | ||
await this.server.revoke(tokenSet.access_token); | ||
@@ -89,3 +94,3 @@ } | ||
// This will try and refresh the token if it is known to be expired | ||
const tokenSet = await this.getTokenSet(undefined); | ||
const tokenSet = await this.getTokenSet('auto'); | ||
const initialUrl = new URL(pathname, tokenSet.aud); | ||
@@ -92,0 +97,0 @@ const initialAuth = `${tokenSet.token_type} ${tokenSet.access_token}`; |
@@ -14,3 +14,3 @@ import { Key } from '@atproto/jwk'; | ||
challenge: string; | ||
method: string; | ||
method: "S256"; | ||
}>; | ||
@@ -17,0 +17,0 @@ calculateJwkThumbprint(jwk: any): Promise<string>; |
import { CachedGetter, GetCachedOptions, SimpleStore } from '@atproto-labs/simple-store'; | ||
import { AtprotoDid } from '@atproto/did'; | ||
import { Key } from '@atproto/jwk'; | ||
@@ -31,3 +32,3 @@ import { TokenInvalidError } from './errors/token-invalid-error.js'; | ||
*/ | ||
export declare class SessionGetter extends CachedGetter<string, Session> { | ||
export declare class SessionGetter extends CachedGetter<AtprotoDid, Session> { | ||
private readonly runtime; | ||
@@ -40,3 +41,3 @@ private readonly eventTarget; | ||
setStored(sub: string, session: Session): Promise<void>; | ||
delStored(sub: string, cause?: unknown): Promise<void>; | ||
delStored(sub: AtprotoDid, cause?: unknown): Promise<void>; | ||
/** | ||
@@ -48,5 +49,5 @@ * @param refresh When `true`, the credentials will be refreshed even if they | ||
*/ | ||
getSession(sub: string, refresh?: boolean): Promise<Session>; | ||
get(sub: string, options?: GetCachedOptions): Promise<Session>; | ||
getSession(sub: AtprotoDid, refresh?: boolean): Promise<Session>; | ||
get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session>; | ||
} | ||
//# sourceMappingURL=session-getter.d.ts.map |
@@ -5,3 +5,3 @@ "use strict"; | ||
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected."); | ||
var dispose; | ||
var dispose, inner; | ||
if (async) { | ||
@@ -14,4 +14,6 @@ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined."); | ||
dispose = value[Symbol.dispose]; | ||
if (async) inner = dispose; | ||
} | ||
if (typeof dispose !== "function") throw new TypeError("Object not disposable."); | ||
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } }; | ||
env.stack.push({ value: value, dispose: dispose, async: async }); | ||
@@ -30,8 +32,12 @@ } | ||
} | ||
var r, s = 0; | ||
function next() { | ||
while (env.stack.length) { | ||
var rec = env.stack.pop(); | ||
while (r = env.stack.pop()) { | ||
try { | ||
var result = rec.dispose && rec.dispose.call(rec.value); | ||
if (rec.async) return Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); | ||
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next); | ||
if (r.dispose) { | ||
var result = r.dispose.call(r.value); | ||
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); | ||
} | ||
else s |= 1; | ||
} | ||
@@ -42,2 +48,3 @@ catch (e) { | ||
} | ||
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve(); | ||
if (env.hasError) throw env.error; | ||
@@ -88,18 +95,19 @@ } | ||
// runtime lock was provided). | ||
if (sub !== storedSession.tokenSet.sub) { | ||
const { dpopKey, tokenSet } = storedSession; | ||
if (sub !== tokenSet.sub) { | ||
// Fool-proofing (e.g. against invalid session storage) | ||
throw new token_refresh_error_js_1.TokenRefreshError(sub, 'Stored session sub mismatch'); | ||
} | ||
if (!tokenSet.refresh_token) { | ||
throw new token_refresh_error_js_1.TokenRefreshError(sub, 'No refresh token available'); | ||
} | ||
// Since refresh tokens can only be used once, we might run into | ||
// concurrency issues if multiple tabs/instances are trying to refresh | ||
// the same token. The chances of this happening when multiple instances | ||
// are started simultaneously is reduced by randomizing the expiry time | ||
// (see isStale() below). Even so, There still exist chances that | ||
// multiple tabs will try to refresh the token at the same time. The | ||
// best solution would be to use a mutex/lock to ensure that only one | ||
// instance is refreshing the token at a time. A simpler workaround is | ||
// to check if the value stored in the session store is the same as the | ||
// one in memory. If it isn't, then another instance has already | ||
// refreshed the token. | ||
const { tokenSet, dpopKey } = storedSession; | ||
// concurrency issues if multiple instances (e.g. browser tabs) are | ||
// trying to refresh the same token simultaneously. The chances of this | ||
// happening when multiple instances are started simultaneously is | ||
// reduced by randomizing the expiry time (see isStale() below). The | ||
// best solution is to use a mutex/lock to ensure that only one instance | ||
// is refreshing the token at a time (runtime.usingLock) but that is not | ||
// always possible. If no lock implementation is provided, we will use | ||
// the store to check if a concurrent refresh occurred. | ||
const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey); | ||
@@ -109,3 +117,3 @@ // Because refresh tokens can only be used once, we must not use the | ||
// point. Any thrown error beyond this point will prevent the | ||
// SessionGetter from obtaining, and storing, the new token set, | ||
// TokenGetter from obtaining, and storing, the new token set, | ||
// effectively rendering the currently saved session unusable. | ||
@@ -147,3 +155,3 @@ options?.signal?.throwIfAborted(); | ||
// A concurrent refresh occurred. Pretend this one succeeded. | ||
return { dpopKey, tokenSet: stored.tokenSet }; | ||
return stored; | ||
} | ||
@@ -165,5 +173,9 @@ else { | ||
new Date(tokenSet.expires_at).getTime() < | ||
// Add some lee way to ensure the token is not expired when it | ||
// reaches the server. | ||
Date.now() + 60e3); | ||
Date.now() + | ||
// Add some lee way to ensure the token is not expired when it | ||
// reaches the server. | ||
10e3 + | ||
// Add some randomness to reduce the chances of multiple | ||
// instances trying to refresh the token at the same. | ||
30e3 * Math.random()); | ||
}, | ||
@@ -205,2 +217,6 @@ onStoreError: async (err, sub, { tokenSet, dpopKey }) => { | ||
async setStored(sub, session) { | ||
// Prevent tampering with the stored value | ||
if (sub !== session.tokenSet.sub) { | ||
throw new TypeError('Token set does not match the expected sub'); | ||
} | ||
await super.setStored(sub, session); | ||
@@ -220,20 +236,19 @@ this.dispatchEvent('updated', { sub, ...session }); | ||
async getSession(sub, refresh) { | ||
const session = await this.get(sub, { | ||
return this.get(sub, { | ||
noCache: refresh === true, | ||
allowStale: refresh === false, | ||
}); | ||
if (sub !== session.tokenSet.sub) { | ||
// Fool-proofing (e.g. against invalid session storage) | ||
throw new Error('Token set does not match the expected sub'); | ||
} | ||
return session; | ||
} | ||
async get(sub, options) { | ||
return this.runtime.usingLock(`@atproto-oauth-client-${sub}`, async () => { | ||
const session = await this.runtime.usingLock(`@atproto-oauth-client-${sub}`, async () => { | ||
const env_1 = { stack: [], error: void 0, hasError: false }; | ||
try { | ||
// Make sure, even if there is no signal in the options, that the request | ||
// will be cancelled after at most 30 seconds. | ||
// Make sure, even if there is no signal in the options, that the | ||
// request will be cancelled after at most 30 seconds. | ||
const signal = __addDisposableResource(env_1, (0, util_js_1.timeoutSignal)(30e3, options), false); | ||
return await super.get(sub, { ...options, signal }); | ||
const abortController = __addDisposableResource(env_1, (0, util_js_1.combineSignals)([options?.signal, signal]), false); | ||
return await super.get(sub, { | ||
...options, | ||
signal: abortController.signal, | ||
}); | ||
} | ||
@@ -248,2 +263,7 @@ catch (e_1) { | ||
}); | ||
if (sub !== session.tokenSet.sub) { | ||
// Fool-proofing (e.g. against invalid session storage) | ||
throw new Error('Token set does not match the expected sub'); | ||
} | ||
return session; | ||
} | ||
@@ -250,0 +270,0 @@ } |
@@ -1,3 +0,5 @@ | ||
/// <reference types="node" /> | ||
export type Awaitable<T> = T | PromiseLike<T>; | ||
export type Simplify<T> = { | ||
[K in keyof T]: T[K]; | ||
} & NonNullable<unknown>; | ||
/** | ||
@@ -20,2 +22,5 @@ * @todo (?) move to common package | ||
} | ||
export type SpaceSeparatedValue<Value extends string> = `${Value}` | `${Value} ${string}` | `${string} ${Value}` | `${string} ${Value} ${string}`; | ||
export declare const includesSpaceSeparatedValue: <Value extends string>(input: string, value: Value) => input is SpaceSeparatedValue<Value>; | ||
export declare function combineSignals(signals: readonly (AbortSignal | undefined)[]): AbortController & Disposable; | ||
//# sourceMappingURL=util.d.ts.map |
@@ -14,3 +14,5 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.CustomEventTarget = exports.CustomEvent = exports.contentMime = exports.timeoutSignal = void 0; | ||
exports.includesSpaceSeparatedValue = exports.CustomEventTarget = exports.CustomEvent = exports.timeoutSignal = void 0; | ||
exports.contentMime = contentMime; | ||
exports.combineSignals = combineSignals; | ||
// @ts-expect-error | ||
@@ -46,3 +48,2 @@ Symbol.dispose ?? (Symbol.dispose = Symbol('@@dispose')); | ||
} | ||
exports.contentMime = contentMime; | ||
/** | ||
@@ -100,2 +101,55 @@ * Ponyfill for `CustomEvent` constructor. | ||
exports.CustomEventTarget = CustomEventTarget; | ||
const includesSpaceSeparatedValue = (input, value) => { | ||
if (value.length === 0) | ||
throw new TypeError('Value cannot be empty'); | ||
if (value.includes(' ')) | ||
throw new TypeError('Value cannot contain spaces'); | ||
// Optimized version of: | ||
// return input.split(' ').includes(value) | ||
const inputLength = input.length; | ||
const valueLength = value.length; | ||
if (inputLength < valueLength) | ||
return false; | ||
let idx = input.indexOf(value); | ||
let idxEnd; | ||
while (idx !== -1) { | ||
idxEnd = idx + valueLength; | ||
if ( | ||
// at beginning or preceded by space | ||
(idx === 0 || input[idx - 1] === ' ') && | ||
// at end or followed by space | ||
(idxEnd === inputLength || input[idxEnd] === ' ')) { | ||
return true; | ||
} | ||
idx = input.indexOf(value, idxEnd + 1); | ||
} | ||
return false; | ||
}; | ||
exports.includesSpaceSeparatedValue = includesSpaceSeparatedValue; | ||
function combineSignals(signals) { | ||
const controller = new AbortController(); | ||
const onAbort = function (_event) { | ||
const reason = new Error('This operation was aborted', { | ||
cause: this.reason, | ||
}); | ||
controller.abort(reason); | ||
}; | ||
for (const sig of signals) { | ||
if (!sig) | ||
continue; | ||
if (sig.aborted) { | ||
// Remove "abort" listener that was added to sig in previous iterations | ||
controller.abort(); | ||
throw new Error('One of the signals is already aborted', { | ||
cause: sig.reason, | ||
}); | ||
} | ||
sig.addEventListener('abort', onAbort, { signal: controller.signal }); | ||
} | ||
controller[Symbol.dispose] = () => { | ||
const reason = new Error('AbortController was disposed'); | ||
controller.abort(reason); | ||
}; | ||
return controller; | ||
} | ||
//# sourceMappingURL=util.js.map |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.validateClientMetadata = void 0; | ||
exports.validateClientMetadata = validateClientMetadata; | ||
const oauth_types_1 = require("@atproto/oauth-types"); | ||
@@ -66,3 +66,2 @@ const types_js_1 = require("./types.js"); | ||
} | ||
exports.validateClientMetadata = validateClientMetadata; | ||
//# sourceMappingURL=validate-client-metadata.js.map |
{ | ||
"name": "@atproto/oauth-client", | ||
"version": "0.2.2", | ||
"version": "0.3.0", | ||
"license": "MIT", | ||
@@ -30,15 +30,15 @@ "description": "OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).", | ||
"zod": "^3.23.8", | ||
"@atproto-labs/did-resolver": "0.1.4", | ||
"@atproto-labs/did-resolver": "0.1.5", | ||
"@atproto-labs/fetch": "0.1.1", | ||
"@atproto-labs/handle-resolver": "0.1.3", | ||
"@atproto-labs/identity-resolver": "0.1.4", | ||
"@atproto-labs/handle-resolver": "0.1.4", | ||
"@atproto-labs/identity-resolver": "0.1.5", | ||
"@atproto-labs/simple-store": "0.1.1", | ||
"@atproto-labs/simple-store-memory": "0.1.1", | ||
"@atproto/did": "0.1.2", | ||
"@atproto/did": "0.1.3", | ||
"@atproto/jwk": "0.1.1", | ||
"@atproto/oauth-types": "0.1.5", | ||
"@atproto/oauth-types": "0.2.0", | ||
"@atproto/xrpc": "0.6.3" | ||
}, | ||
"devDependencies": { | ||
"typescript": "^5.3.3" | ||
"typescript": "^5.6.3" | ||
}, | ||
@@ -45,0 +45,0 @@ "scripts": { |
@@ -14,3 +14,3 @@ # @atproto/oauth-client: atproto flavoured OAuth client | ||
```ts | ||
import { OAuthClient } from '@atproto/oauth-client' | ||
import { OAuthClient, Key, Session } from '@atproto/oauth-client' | ||
import { JoseKey } from '@atproto/jwk-jose' // NodeJS/Browser only | ||
@@ -65,3 +65,6 @@ | ||
requestLock: <T>(name: string, fn: () => T | PromiseLike<T>): Promise T => { | ||
requestLock: <T>( | ||
name: string, | ||
fn: () => T | PromiseLike<T>, | ||
): Promise<T> => { | ||
// This function is used to prevent concurrent refreshes of the same | ||
@@ -79,9 +82,12 @@ // credentials. It is important to ensure that only one refresh is done at | ||
const current = locks.get(name) || Promise.resolve() | ||
const next = current.then(fn).catch(() => {}).finally(() => { | ||
if (locks.get(name) === next) locks.delete(name) | ||
}) | ||
const next = current | ||
.then(fn) | ||
.catch(() => {}) | ||
.finally(() => { | ||
if (locks.get(name) === next) locks.delete(name) | ||
}) | ||
locks.set(name, next) | ||
return next | ||
} | ||
}, | ||
}, | ||
@@ -88,0 +94,0 @@ |
@@ -17,3 +17,3 @@ import { | ||
} from '@atproto/oauth-types' | ||
import { contentMime } from './util' | ||
import { contentMime } from './util.js' | ||
@@ -27,2 +27,6 @@ export type { GetCachedOptions, OAuthAuthorizationServerMetadata } | ||
export type OAuthAuthorizationServerMetadataResolverConfig = { | ||
allowHttpIssuer?: boolean | ||
} | ||
/** | ||
@@ -36,14 +40,26 @@ * @see {@link https://datatracker.ietf.org/doc/html/rfc8414} | ||
private readonly fetch: Fetch<unknown> | ||
private readonly allowHttpIssuer: boolean | ||
constructor(cache: AuthorizationServerMetadataCache, fetch?: Fetch) { | ||
constructor( | ||
cache: AuthorizationServerMetadataCache, | ||
fetch?: Fetch, | ||
config?: OAuthAuthorizationServerMetadataResolverConfig, | ||
) { | ||
super(async (issuer, options) => this.fetchMetadata(issuer, options), cache) | ||
this.fetch = bindFetch(fetch) | ||
this.allowHttpIssuer = config?.allowHttpIssuer === true | ||
} | ||
async get( | ||
issuer: string, | ||
input: string, | ||
options?: GetCachedOptions, | ||
): Promise<OAuthAuthorizationServerMetadata> { | ||
return super.get(oauthIssuerIdentifierSchema.parse(issuer), options) | ||
const issuer = oauthIssuerIdentifierSchema.parse(input) | ||
if (!this.allowHttpIssuer && issuer.startsWith('http:')) { | ||
throw new TypeError( | ||
'Unsecure issuer URL protocol only allowed in development and test environments', | ||
) | ||
} | ||
return super.get(issuer, options) | ||
} | ||
@@ -55,9 +71,7 @@ | ||
): Promise<OAuthAuthorizationServerMetadata> { | ||
const headers = new Headers([['accept', 'application/json']]) | ||
if (options?.noCache) headers.set('cache-control', 'no-cache') | ||
const url = new URL(`/.well-known/oauth-authorization-server`, issuer) | ||
const request = new Request(url, { | ||
headers: { accept: 'application/json' }, | ||
cache: options?.noCache ? 'no-cache' : undefined, | ||
signal: options?.signal, | ||
headers, | ||
redirect: 'manual', // response must be 200 OK | ||
@@ -64,0 +78,0 @@ }) |
import { | ||
assertAtprotoDid, | ||
AtprotoDid, | ||
DidCache, | ||
DidResolverCached, | ||
DidResolverCommon, | ||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | ||
type DidResolverCommonOptions, | ||
} from '@atproto-labs/did-resolver' | ||
@@ -17,2 +21,3 @@ import { Fetch } from '@atproto-labs/fetch' | ||
import { | ||
OAuthAuthorizationRequestParameters, | ||
OAuthClientIdDiscoverable, | ||
@@ -77,2 +82,17 @@ OAuthClientMetadata, | ||
keyset?: Keyset | Iterable<Key | undefined | null | false> | ||
/** | ||
* Determines if the client will allow communicating with the OAuth Servers | ||
* (Authorization & Resource), or to retrieve "did:web" documents, over | ||
* unsafe HTTP connections. It is recommended to set this to `true` only for | ||
* development purposes. | ||
* | ||
* @note This does not affect the identity resolution mechanism, which will | ||
* allow HTTP connections to the PLC Directory (if the provided directory url | ||
* is "http:" based). | ||
* @default false | ||
* @see {@link OAuthProtectedResourceMetadataResolver.allowHttpResource} | ||
* @see {@link OAuthAuthorizationServerMetadataResolver.allowHttpIssuer} | ||
* @see {@link DidResolverCommonOptions.allowHttp} | ||
*/ | ||
allowHttp?: boolean | ||
@@ -148,7 +168,8 @@ // Stores | ||
// Stores | ||
readonly sessionGetter: SessionGetter | ||
readonly stateStore: StateStore | ||
protected readonly sessionGetter: SessionGetter | ||
protected readonly stateStore: StateStore | ||
constructor({ | ||
fetch = globalThis.fetch, | ||
allowHttp = false, | ||
@@ -192,3 +213,3 @@ stateStore, | ||
new DidResolverCached( | ||
new DidResolverCommon({ fetch, plcDirectoryUrl }), | ||
new DidResolverCommon({ fetch, plcDirectoryUrl, allowHttp }), | ||
didCache, | ||
@@ -204,2 +225,3 @@ ), | ||
fetch, | ||
{ allowHttpResource: allowHttp }, | ||
), | ||
@@ -209,2 +231,3 @@ new OAuthAuthorizationServerMetadataResolver( | ||
fetch, | ||
{ allowHttpIssuer: allowHttp }, | ||
), | ||
@@ -257,3 +280,6 @@ ) | ||
async authorize(input: string, options?: AuthorizeOptions): Promise<URL> { | ||
async authorize( | ||
input: string, | ||
{ signal, ...options }: AuthorizeOptions = {}, | ||
): Promise<URL> { | ||
const redirectUri = | ||
@@ -266,6 +292,5 @@ options?.redirect_uri ?? this.clientMetadata.redirect_uris[0] | ||
const { identity, metadata } = await this.oauthResolver.resolve( | ||
input, | ||
options, | ||
) | ||
const { identity, metadata } = await this.oauthResolver.resolve(input, { | ||
signal, | ||
}) | ||
@@ -286,3 +311,5 @@ const pkce = await this.runtime.generatePKCE() | ||
const parameters = { | ||
const parameters: OAuthAuthorizationRequestParameters = { | ||
...options, | ||
client_id: this.clientMetadata.client_id, | ||
@@ -297,8 +324,4 @@ redirect_uri: redirectUri, | ||
response_mode: this.responseMode, | ||
response_type: 'code', | ||
display: options?.display, | ||
prompt: options?.prompt, | ||
response_type: 'code' as const, | ||
scope: options?.scope ?? this.clientMetadata.scope, | ||
ui_locales: options?.ui_locales, | ||
} | ||
@@ -409,3 +432,3 @@ | ||
if (issuerParam != null) { | ||
if (!server.serverMetadata.issuer) { | ||
if (!server.issuer) { | ||
throw new OAuthCallbackError( | ||
@@ -417,3 +440,3 @@ params, | ||
} | ||
if (server.serverMetadata.issuer !== issuerParam) { | ||
if (server.issuer !== issuerParam) { | ||
throw new OAuthCallbackError( | ||
@@ -446,3 +469,3 @@ params, | ||
} catch (err) { | ||
await server.revoke(tokenSet.access_token) | ||
await server.revoke(tokenSet.refresh_token || tokenSet.access_token) | ||
@@ -464,8 +487,14 @@ throw err | ||
*/ | ||
async restore(sub: string, refresh?: boolean): Promise<OAuthSession> { | ||
const { dpopKey, tokenSet } = await this.sessionGetter.getSession( | ||
sub, | ||
refresh, | ||
) | ||
async restore( | ||
sub: string, | ||
refresh: boolean | 'auto' = 'auto', | ||
): Promise<OAuthSession> { | ||
// sub arg is lightly typed for convenience of library user | ||
assertAtprotoDid(sub) | ||
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, { | ||
noCache: refresh === true, | ||
allowStale: refresh === false, | ||
}) | ||
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey, { | ||
@@ -480,7 +509,9 @@ noCache: refresh === true, | ||
async revoke(sub: string) { | ||
const { dpopKey, tokenSet } = await this.sessionGetter.getSession( | ||
sub, | ||
false, | ||
) | ||
// sub arg is lightly typed for convenience of library user | ||
assertAtprotoDid(sub) | ||
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, { | ||
allowStale: true, | ||
}) | ||
// NOT using `;(await this.restore(sub, false)).signOut()` because we want | ||
@@ -497,5 +528,8 @@ // the tokens to be deleted even if it was not possible to fetch the issuer | ||
protected createSession(server: OAuthServerAgent, sub: string): OAuthSession { | ||
protected createSession( | ||
server: OAuthServerAgent, | ||
sub: AtprotoDid, | ||
): OAuthSession { | ||
return new OAuthSession(server, sub, this.sessionGetter, this.fetch) | ||
} | ||
} |
@@ -13,7 +13,6 @@ import { | ||
import { | ||
ALLOW_UNSECURE_ORIGINS, | ||
OAuthProtectedResourceMetadata, | ||
oauthProtectedResourceMetadataSchema, | ||
} from '@atproto/oauth-types' | ||
import { contentMime } from './util' | ||
import { contentMime } from './util.js' | ||
@@ -27,2 +26,6 @@ export type { GetCachedOptions, OAuthProtectedResourceMetadata } | ||
export type OAuthProtectedResourceMetadataResolverConfig = { | ||
allowHttpResource?: boolean | ||
} | ||
/** | ||
@@ -36,2 +39,3 @@ * @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05} | ||
private readonly fetch: Fetch<unknown> | ||
private readonly allowHttpResource: boolean | ||
@@ -41,2 +45,3 @@ constructor( | ||
fetch: Fetch = globalThis.fetch, | ||
config?: OAuthProtectedResourceMetadataResolverConfig, | ||
) { | ||
@@ -46,2 +51,3 @@ super(async (origin, options) => this.fetchMetadata(origin, options), cache) | ||
this.fetch = bindFetch(fetch) | ||
this.allowHttpResource = config?.allowHttpResource === true | ||
} | ||
@@ -54,10 +60,16 @@ | ||
const { protocol, origin } = new URL(resource) | ||
if ( | ||
protocol === 'https:' || | ||
(protocol === 'http:' && ALLOW_UNSECURE_ORIGINS) | ||
) { | ||
return super.get(origin, options) | ||
if (protocol !== 'https:' && protocol !== 'http:') { | ||
throw new TypeError( | ||
`Invalid protected resource metadata URL protocol: ${protocol}`, | ||
) | ||
} | ||
throw new TypeError(`Forbidden resource sercure protocol "${protocol}"`) | ||
if (protocol === 'http:' && !this.allowHttpResource) { | ||
throw new TypeError( | ||
`Unsecure resource metadata URL (${protocol}) only allowed in development and test environments`, | ||
) | ||
} | ||
return super.get(origin, options) | ||
} | ||
@@ -69,9 +81,7 @@ | ||
): Promise<OAuthProtectedResourceMetadata> { | ||
const headers = new Headers([['accept', 'application/json']]) | ||
if (options?.noCache) headers.set('cache-control', 'no-cache') | ||
const url = new URL(`/.well-known/oauth-protected-resource`, origin) | ||
const request = new Request(url, { | ||
signal: options?.signal, | ||
headers, | ||
headers: { accept: 'application/json' }, | ||
cache: options?.noCache ? 'no-cache' : undefined, | ||
redirect: 'manual', // response must be 200 OK | ||
@@ -78,0 +88,0 @@ }) |
import { Fetch, Json, bindFetch, fetchJsonProcessor } from '@atproto-labs/fetch' | ||
import { SimpleStore } from '@atproto-labs/simple-store' | ||
import { AtprotoDid } from '@atproto/did' | ||
import { Key, Keyset } from '@atproto/jwk' | ||
import { | ||
CLIENT_ASSERTION_TYPE_JWT_BEARER, | ||
OAuthAuthorizationRequestPar, | ||
OAuthAuthorizationServerMetadata, | ||
@@ -10,8 +12,11 @@ OAuthClientCredentials, | ||
OAuthParResponse, | ||
OAuthTokenResponse, | ||
OAuthTokenType, | ||
OAuthTokenRequest, | ||
oauthParResponseSchema, | ||
oauthTokenResponseSchema, | ||
} from '@atproto/oauth-types' | ||
import { | ||
AtprotoScope, | ||
AtprotoTokenResponse, | ||
atprotoTokenResponseSchema, | ||
} from './atproto-token-response.js' | ||
import { FALLBACK_ALG } from './constants.js' | ||
@@ -28,9 +33,9 @@ import { TokenRefreshError } from './errors/token-refresh-error.js' | ||
iss: string | ||
sub: string | ||
sub: AtprotoDid | ||
aud: string | ||
scope: string | ||
scope: AtprotoScope | ||
refresh_token?: string | ||
access_token: string | ||
token_type: OAuthTokenType | ||
token_type: 'DPoP' | ||
/** ISO Date */ | ||
@@ -66,2 +71,6 @@ expires_at?: string | ||
get issuer() { | ||
return this.serverMetadata.issuer | ||
} | ||
async revoke(token: string) { | ||
@@ -75,3 +84,5 @@ try { | ||
async exchangeCode(code: string, verifier?: string): Promise<TokenSet> { | ||
async exchangeCode(code: string, codeVerifier?: string): Promise<TokenSet> { | ||
const now = Date.now() | ||
const tokenResponse = await this.request('token', { | ||
@@ -81,7 +92,27 @@ grant_type: 'authorization_code', | ||
code, | ||
code_verifier: verifier, | ||
code_verifier: codeVerifier, | ||
}) | ||
try { | ||
return this.processTokenResponse(tokenResponse) | ||
// /!\ IMPORTANT /!\ | ||
// | ||
// The tokenResponse MUST always be valid before the "sub" it contains | ||
// can be trusted (see Atproto's OAuth spec for details). | ||
const aud = await this.verifyIssuer(tokenResponse.sub) | ||
return { | ||
aud, | ||
sub: tokenResponse.sub, | ||
iss: this.issuer, | ||
scope: tokenResponse.scope, | ||
refresh_token: tokenResponse.refresh_token, | ||
access_token: tokenResponse.access_token, | ||
token_type: tokenResponse.token_type, | ||
expires_at: | ||
typeof tokenResponse.expires_in === 'number' | ||
? new Date(now + tokenResponse.expires_in * 1000).toISOString() | ||
: undefined, | ||
} | ||
} catch (err) { | ||
@@ -99,2 +130,14 @@ await this.revoke(tokenResponse.access_token) | ||
// /!\ IMPORTANT /!\ | ||
// | ||
// The "sub" MUST be a DID, whose issuer authority is indeed the server we | ||
// are trying to obtain credentials from. Note that we are doing this | ||
// *before* we actually try to refresh the token: | ||
// 1) To avoid unnecessary refresh | ||
// 2) So that the refresh is the last async operation, ensuring as few | ||
// async operations happen before the result gets a chance to be stored. | ||
const aud = await this.verifyIssuer(tokenSet.sub) | ||
const now = Date.now() | ||
const tokenResponse = await this.request('token', { | ||
@@ -105,18 +148,16 @@ grant_type: 'refresh_token', | ||
try { | ||
if (tokenSet.sub !== tokenResponse.sub) { | ||
throw new TokenRefreshError( | ||
tokenSet.sub, | ||
`Unexpected "sub" in token response (${tokenResponse.sub})`, | ||
) | ||
} | ||
if (tokenSet.iss !== this.serverMetadata.issuer) { | ||
throw new TokenRefreshError(tokenSet.sub, 'Issuer mismatch') | ||
} | ||
return { | ||
aud, | ||
sub: tokenSet.sub, | ||
iss: this.issuer, | ||
return this.processTokenResponse(tokenResponse) | ||
} catch (err) { | ||
await this.revoke(tokenResponse.access_token) | ||
scope: tokenResponse.scope, | ||
refresh_token: tokenResponse.refresh_token, | ||
access_token: tokenResponse.access_token, | ||
token_type: tokenResponse.token_type, | ||
throw err | ||
expires_at: | ||
typeof tokenResponse.expires_in === 'number' | ||
? new Date(now + tokenResponse.expires_in * 1000).toISOString() | ||
: undefined, | ||
} | ||
@@ -132,27 +173,15 @@ } | ||
* able to use the "sub" (DID) as being the actual user's identifier. | ||
* | ||
* @returns The user's PDS URL (the resource server for the user) | ||
*/ | ||
private async processTokenResponse( | ||
tokenResponse: OAuthTokenResponse, | ||
): Promise<TokenSet> { | ||
const { sub } = tokenResponse | ||
if (!sub || typeof sub !== 'string') { | ||
throw new TypeError(`Unexpected ${typeof sub} "sub" in token response`) | ||
} | ||
// Using an array to check for the presence of the "atproto" scope (we don't | ||
// want atproto to be a substring of another scope) | ||
const scopes = tokenResponse.scope?.split(' ') | ||
if (!scopes?.includes('atproto')) { | ||
throw new TypeError('Missing "atproto" scope in token response') | ||
} | ||
// @TODO (?) make timeout configurable | ||
protected async verifyIssuer(sub: AtprotoDid) { | ||
using signal = timeoutSignal(10e3) | ||
const resolved = await this.oauthResolver.resolveFromIdentity(sub, { | ||
noCache: true, | ||
allowStale: false, | ||
signal, | ||
}) | ||
if (this.serverMetadata.issuer !== resolved.metadata.issuer) { | ||
if (this.issuer !== resolved.metadata.issuer) { | ||
// Best case scenario; the user switched PDS. Worst case scenario; a bad | ||
@@ -164,33 +193,23 @@ // actor is trying to impersonate a user. In any case, we must not allow | ||
return { | ||
aud: resolved.identity.pds.href, | ||
iss: resolved.metadata.issuer, | ||
sub, | ||
scope: tokenResponse.scope!, | ||
refresh_token: tokenResponse.refresh_token, | ||
access_token: tokenResponse.access_token, | ||
token_type: tokenResponse.token_type ?? 'Bearer', | ||
expires_at: | ||
typeof tokenResponse.expires_in === 'number' | ||
? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString() | ||
: undefined, | ||
} | ||
return resolved.identity.pds.href | ||
} | ||
async request<Endpoint extends OAuthEndpointName>( | ||
endpoint: Endpoint, | ||
payload: Endpoint extends 'token' | ||
? OAuthTokenRequest | ||
: Endpoint extends 'pushed_authorization_request' | ||
? OAuthAuthorizationRequestPar | ||
: Record<string, unknown>, | ||
): Promise< | ||
Endpoint extends 'token' | ||
? AtprotoTokenResponse | ||
: Endpoint extends 'pushed_authorization_request' | ||
? OAuthParResponse | ||
: Json | ||
> | ||
async request( | ||
endpoint: 'token', | ||
payload: Record<string, unknown>, | ||
): Promise<OAuthTokenResponse> | ||
async request( | ||
endpoint: 'pushed_authorization_request', | ||
payload: Record<string, unknown>, | ||
): Promise<OAuthParResponse> | ||
async request( | ||
endpoint: OAuthEndpointName, | ||
payload: Record<string, unknown>, | ||
): Promise<Json> | ||
async request(endpoint: OAuthEndpointName, payload: Record<string, unknown>) { | ||
): Promise<unknown> { | ||
const url = this.serverMetadata[`${endpoint}_endpoint`] | ||
@@ -210,3 +229,3 @@ if (!url) throw new Error(`No ${endpoint} endpoint available`) | ||
case 'token': | ||
return oauthTokenResponseSchema.parse(json) | ||
return atprotoTokenResponseSchema.parse(json) | ||
case 'pushed_authorization_request': | ||
@@ -213,0 +232,0 @@ return oauthParResponseSchema.parse(json) |
@@ -1,5 +0,6 @@ | ||
import { asDid } from '@atproto/did' | ||
import { Fetch, bindFetch } from '@atproto-labs/fetch' | ||
import { bindFetch, Fetch } from '@atproto-labs/fetch' | ||
import { AtprotoDid } from '@atproto/did' | ||
import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types' | ||
import { AtprotoScope } from './atproto-token-response.js' | ||
import { TokenInvalidError } from './errors/token-invalid-error.js' | ||
@@ -18,6 +19,6 @@ import { TokenRevokedError } from './errors/token-revoked-error.js' | ||
expired?: boolean | ||
scope?: string | ||
scope: AtprotoScope | ||
iss: string | ||
aud: string | ||
sub: string | ||
sub: AtprotoDid | ||
} | ||
@@ -30,3 +31,3 @@ | ||
public readonly server: OAuthServerAgent, | ||
public readonly sub: string, | ||
public readonly sub: AtprotoDid, | ||
private readonly sessionGetter: SessionGetter, | ||
@@ -46,4 +47,4 @@ fetch: Fetch = globalThis.fetch, | ||
get did() { | ||
return asDid(this.sub) | ||
get did(): AtprotoDid { | ||
return this.sub | ||
} | ||
@@ -56,10 +57,17 @@ | ||
/** | ||
* @param refresh See {@link SessionGetter.getSession} | ||
* @param refresh When `true`, the credentials will be refreshed even if they | ||
* are not expired. When `false`, the credentials will not be refreshed even | ||
* if they are expired. When `undefined`, the credentials will be refreshed | ||
* if, and only if, they are (about to be) expired. Defaults to `undefined`. | ||
*/ | ||
public async getTokenSet(refresh?: boolean): Promise<TokenSet> { | ||
const { tokenSet } = await this.sessionGetter.getSession(this.sub, refresh) | ||
protected async getTokenSet(refresh: boolean | 'auto'): Promise<TokenSet> { | ||
const { tokenSet } = await this.sessionGetter.get(this.sub, { | ||
noCache: refresh === true, | ||
allowStale: refresh === false, | ||
}) | ||
return tokenSet | ||
} | ||
async getTokenInfo(refresh?: boolean): Promise<TokenInfo> { | ||
async getTokenInfo(refresh: boolean | 'auto' = 'auto'): Promise<TokenInfo> { | ||
const tokenSet = await this.getTokenSet(refresh) | ||
@@ -85,3 +93,3 @@ const expiresAt = | ||
try { | ||
const { tokenSet } = await this.sessionGetter.getSession(this.sub, false) | ||
const tokenSet = await this.getTokenSet(false) | ||
await this.server.revoke(tokenSet.access_token) | ||
@@ -98,3 +106,3 @@ } finally { | ||
// This will try and refresh the token if it is known to be expired | ||
const tokenSet = await this.getTokenSet(undefined) | ||
const tokenSet = await this.getTokenSet('auto') | ||
@@ -101,0 +109,0 @@ const initialUrl = new URL(pathname, tokenSet.aud) |
@@ -42,3 +42,3 @@ import { Key } from '@atproto/jwk' | ||
challenge: await this.sha256(verifier), | ||
method: 'S256', | ||
method: 'S256' as const, | ||
} | ||
@@ -45,0 +45,0 @@ } |
@@ -6,2 +6,3 @@ import { | ||
} from '@atproto-labs/simple-store' | ||
import { AtprotoDid } from '@atproto/did' | ||
import { Key } from '@atproto/jwk' | ||
@@ -16,3 +17,3 @@ | ||
import { Runtime } from './runtime.js' | ||
import { CustomEventTarget, timeoutSignal } from './util.js' | ||
import { combineSignals, CustomEventTarget, timeoutSignal } from './util.js' | ||
@@ -47,3 +48,3 @@ export type Session = { | ||
*/ | ||
export class SessionGetter extends CachedGetter<string, Session> { | ||
export class SessionGetter extends CachedGetter<AtprotoDid, Session> { | ||
private readonly eventTarget = new CustomEventTarget<SessionEventMap>() | ||
@@ -79,3 +80,5 @@ | ||
if (sub !== storedSession.tokenSet.sub) { | ||
const { dpopKey, tokenSet } = storedSession | ||
if (sub !== tokenSet.sub) { | ||
// Fool-proofing (e.g. against invalid session storage) | ||
@@ -85,15 +88,16 @@ throw new TokenRefreshError(sub, 'Stored session sub mismatch') | ||
if (!tokenSet.refresh_token) { | ||
throw new TokenRefreshError(sub, 'No refresh token available') | ||
} | ||
// Since refresh tokens can only be used once, we might run into | ||
// concurrency issues if multiple tabs/instances are trying to refresh | ||
// the same token. The chances of this happening when multiple instances | ||
// are started simultaneously is reduced by randomizing the expiry time | ||
// (see isStale() below). Even so, There still exist chances that | ||
// multiple tabs will try to refresh the token at the same time. The | ||
// best solution would be to use a mutex/lock to ensure that only one | ||
// instance is refreshing the token at a time. A simpler workaround is | ||
// to check if the value stored in the session store is the same as the | ||
// one in memory. If it isn't, then another instance has already | ||
// refreshed the token. | ||
// concurrency issues if multiple instances (e.g. browser tabs) are | ||
// trying to refresh the same token simultaneously. The chances of this | ||
// happening when multiple instances are started simultaneously is | ||
// reduced by randomizing the expiry time (see isStale() below). The | ||
// best solution is to use a mutex/lock to ensure that only one instance | ||
// is refreshing the token at a time (runtime.usingLock) but that is not | ||
// always possible. If no lock implementation is provided, we will use | ||
// the store to check if a concurrent refresh occurred. | ||
const { tokenSet, dpopKey } = storedSession | ||
const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey) | ||
@@ -104,3 +108,3 @@ | ||
// point. Any thrown error beyond this point will prevent the | ||
// SessionGetter from obtaining, and storing, the new token set, | ||
// TokenGetter from obtaining, and storing, the new token set, | ||
// effectively rendering the currently saved session unusable. | ||
@@ -149,3 +153,3 @@ options?.signal?.throwIfAborted() | ||
// A concurrent refresh occurred. Pretend this one succeeded. | ||
return { dpopKey, tokenSet: stored.tokenSet } | ||
return stored | ||
} else { | ||
@@ -171,5 +175,9 @@ // There were no concurrent refresh. The token is (likely) | ||
new Date(tokenSet.expires_at).getTime() < | ||
// Add some lee way to ensure the token is not expired when it | ||
// reaches the server. | ||
Date.now() + 60e3 | ||
Date.now() + | ||
// Add some lee way to ensure the token is not expired when it | ||
// reaches the server. | ||
10e3 + | ||
// Add some randomness to reduce the chances of multiple | ||
// instances trying to refresh the token at the same. | ||
30e3 * Math.random() | ||
) | ||
@@ -216,2 +224,6 @@ }, | ||
async setStored(sub: string, session: Session) { | ||
// Prevent tampering with the stored value | ||
if (sub !== session.tokenSet.sub) { | ||
throw new TypeError('Token set does not match the expected sub') | ||
} | ||
await super.setStored(sub, session) | ||
@@ -221,3 +233,3 @@ this.dispatchEvent('updated', { sub, ...session }) | ||
async delStored(sub: string, cause?: unknown): Promise<void> { | ||
override async delStored(sub: AtprotoDid, cause?: unknown): Promise<void> { | ||
await super.delStored(sub, cause) | ||
@@ -233,8 +245,26 @@ this.dispatchEvent('deleted', { sub, cause }) | ||
*/ | ||
async getSession(sub: string, refresh?: boolean) { | ||
const session = await this.get(sub, { | ||
async getSession(sub: AtprotoDid, refresh?: boolean) { | ||
return this.get(sub, { | ||
noCache: refresh === true, | ||
allowStale: refresh === false, | ||
}) | ||
} | ||
async get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session> { | ||
const session = await this.runtime.usingLock( | ||
`@atproto-oauth-client-${sub}`, | ||
async () => { | ||
// Make sure, even if there is no signal in the options, that the | ||
// request will be cancelled after at most 30 seconds. | ||
using signal = timeoutSignal(30e3, options) | ||
using abortController = combineSignals([options?.signal, signal]) | ||
return await super.get(sub, { | ||
...options, | ||
signal: abortController.signal, | ||
}) | ||
}, | ||
) | ||
if (sub !== session.tokenSet.sub) { | ||
@@ -247,12 +277,2 @@ // Fool-proofing (e.g. against invalid session storage) | ||
} | ||
async get(sub: string, options?: GetCachedOptions): Promise<Session> { | ||
return this.runtime.usingLock(`@atproto-oauth-client-${sub}`, async () => { | ||
// Make sure, even if there is no signal in the options, that the request | ||
// will be cancelled after at most 30 seconds. | ||
using signal = timeoutSignal(30e3, options) | ||
return await super.get(sub, { ...options, signal }) | ||
}) | ||
} | ||
} |
import { | ||
OAuthAuthorizationRequestParameters, | ||
oauthClientIdSchema, | ||
@@ -7,2 +8,4 @@ oauthClientMetadataSchema, | ||
import { Simplify } from './util.js' | ||
// Note: These types are not prefixed with `OAuth` because they are not specific | ||
@@ -12,14 +15,16 @@ // to OAuth. They are specific to this packages. OAuth specific types are in | ||
export type AuthorizeOptions = { | ||
display?: 'page' | 'popup' | 'touch' | 'wap' | ||
redirect_uri?: string | ||
prompt?: 'login' | 'none' | 'consent' | 'select_account' | ||
scope?: string | ||
state?: string | ||
signal?: AbortSignal | ||
export type AuthorizeOptions = Simplify< | ||
Omit< | ||
OAuthAuthorizationRequestParameters, | ||
| 'client_id' | ||
| 'response_mode' | ||
| 'response_type' | ||
| 'login_hint' | ||
| 'code_challenge' | ||
| 'code_challenge_method' | ||
> & { | ||
signal?: AbortSignal | ||
} | ||
> | ||
// Borrowed from OIDC | ||
ui_locales?: string | ||
} | ||
export const clientMetadataSchema = oauthClientMetadataSchema.extend({ | ||
@@ -26,0 +31,0 @@ client_id: oauthClientIdSchema.url(), |
export type Awaitable<T> = T | PromiseLike<T> | ||
export type Simplify<T> = { [K in keyof T]: T[K] } & NonNullable<unknown> | ||
@@ -119,1 +120,78 @@ // @ts-expect-error | ||
} | ||
export type SpaceSeparatedValue<Value extends string> = | ||
| `${Value}` | ||
| `${Value} ${string}` | ||
| `${string} ${Value}` | ||
| `${string} ${Value} ${string}` | ||
export const includesSpaceSeparatedValue = <Value extends string>( | ||
input: string, | ||
value: Value, | ||
): input is SpaceSeparatedValue<Value> => { | ||
if (value.length === 0) throw new TypeError('Value cannot be empty') | ||
if (value.includes(' ')) throw new TypeError('Value cannot contain spaces') | ||
// Optimized version of: | ||
// return input.split(' ').includes(value) | ||
const inputLength = input.length | ||
const valueLength = value.length | ||
if (inputLength < valueLength) return false | ||
let idx = input.indexOf(value) | ||
let idxEnd: number | ||
while (idx !== -1) { | ||
idxEnd = idx + valueLength | ||
if ( | ||
// at beginning or preceded by space | ||
(idx === 0 || input[idx - 1] === ' ') && | ||
// at end or followed by space | ||
(idxEnd === inputLength || input[idxEnd] === ' ') | ||
) { | ||
return true | ||
} | ||
idx = input.indexOf(value, idxEnd + 1) | ||
} | ||
return false | ||
} | ||
export function combineSignals(signals: readonly (AbortSignal | undefined)[]) { | ||
const controller = new AbortController() | ||
const onAbort = function (this: AbortSignal, _event: Event) { | ||
const reason = new Error('This operation was aborted', { | ||
cause: this.reason, | ||
}) | ||
controller.abort(reason) | ||
} | ||
for (const sig of signals) { | ||
if (!sig) continue | ||
if (sig.aborted) { | ||
// Remove "abort" listener that was added to sig in previous iterations | ||
controller.abort() | ||
throw new Error('One of the signals is already aborted', { | ||
cause: sig.reason, | ||
}) | ||
} | ||
sig.addEventListener('abort', onAbort, { signal: controller.signal }) | ||
} | ||
controller[Symbol.dispose] = () => { | ||
const reason = new Error('AbortController was disposed') | ||
controller.abort(reason) | ||
} | ||
return controller as AbortController & Disposable | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
404863
132
6719
388
+ Added@atproto-labs/did-resolver@0.1.5(transitive)
+ Added@atproto-labs/handle-resolver@0.1.4(transitive)
+ Added@atproto-labs/identity-resolver@0.1.5(transitive)
+ Added@atproto/did@0.1.3(transitive)
+ Added@atproto/oauth-types@0.2.0(transitive)
- Removed@atproto-labs/did-resolver@0.1.4(transitive)
- Removed@atproto-labs/handle-resolver@0.1.3(transitive)
- Removed@atproto-labs/identity-resolver@0.1.4(transitive)
- Removed@atproto/did@0.1.2(transitive)
- Removed@atproto/oauth-types@0.1.5(transitive)
Updated@atproto/did@0.1.3
Updated@atproto/oauth-types@0.2.0