@atproto/oauth-client 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (111) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE.txt +7 -0
  3. package/README.md +124 -0
  4. package/dist/constants.d.ts +5 -0
  5. package/dist/constants.d.ts.map +1 -0
  6. package/dist/constants.js +8 -0
  7. package/dist/constants.js.map +1 -0
  8. package/dist/fetch-dpop.d.ts +21 -0
  9. package/dist/fetch-dpop.d.ts.map +1 -0
  10. package/dist/fetch-dpop.js +149 -0
  11. package/dist/fetch-dpop.js.map +1 -0
  12. package/dist/index.d.ts +15 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +35 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/lock.d.ts +2 -0
  17. package/dist/lock.d.ts.map +1 -0
  18. package/dist/lock.js +33 -0
  19. package/dist/lock.js.map +1 -0
  20. package/dist/oauth-agent.d.ts +29 -0
  21. package/dist/oauth-agent.d.ts.map +1 -0
  22. package/dist/oauth-agent.js +138 -0
  23. package/dist/oauth-agent.js.map +1 -0
  24. package/dist/oauth-authorization-server-metadata-resolver.d.ts +15 -0
  25. package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -0
  26. package/dist/oauth-authorization-server-metadata-resolver.js +56 -0
  27. package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -0
  28. package/dist/oauth-callback-error.d.ts +7 -0
  29. package/dist/oauth-callback-error.d.ts.map +1 -0
  30. package/dist/oauth-callback-error.js +28 -0
  31. package/dist/oauth-callback-error.js.map +1 -0
  32. package/dist/oauth-client.d.ts +78 -0
  33. package/dist/oauth-client.d.ts.map +1 -0
  34. package/dist/oauth-client.js +278 -0
  35. package/dist/oauth-client.js.map +1 -0
  36. package/dist/oauth-protected-resource-metadata-resolver.d.ts +15 -0
  37. package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -0
  38. package/dist/oauth-protected-resource-metadata-resolver.js +58 -0
  39. package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -0
  40. package/dist/oauth-resolver-error.d.ts +7 -0
  41. package/dist/oauth-resolver-error.d.ts.map +1 -0
  42. package/dist/oauth-resolver-error.js +17 -0
  43. package/dist/oauth-resolver-error.js.map +1 -0
  44. package/dist/oauth-resolver.d.ts +62 -0
  45. package/dist/oauth-resolver.d.ts.map +1 -0
  46. package/dist/oauth-resolver.js +73 -0
  47. package/dist/oauth-resolver.js.map +1 -0
  48. package/dist/oauth-response-error.d.ts +11 -0
  49. package/dist/oauth-response-error.d.ts.map +1 -0
  50. package/dist/oauth-response-error.js +48 -0
  51. package/dist/oauth-response-error.js.map +1 -0
  52. package/dist/oauth-server-agent.d.ts +51 -0
  53. package/dist/oauth-server-agent.d.ts.map +1 -0
  54. package/dist/oauth-server-agent.js +228 -0
  55. package/dist/oauth-server-agent.js.map +1 -0
  56. package/dist/oauth-server-factory.d.ts +20 -0
  57. package/dist/oauth-server-factory.d.ts.map +1 -0
  58. package/dist/oauth-server-factory.js +53 -0
  59. package/dist/oauth-server-factory.js.map +1 -0
  60. package/dist/refresh-error.d.ts +7 -0
  61. package/dist/refresh-error.d.ts.map +1 -0
  62. package/dist/refresh-error.js +16 -0
  63. package/dist/refresh-error.js.map +1 -0
  64. package/dist/runtime-implementation.d.ts +12 -0
  65. package/dist/runtime-implementation.d.ts.map +1 -0
  66. package/dist/runtime-implementation.js +3 -0
  67. package/dist/runtime-implementation.js.map +1 -0
  68. package/dist/runtime.d.ts +35 -0
  69. package/dist/runtime.d.ts.map +1 -0
  70. package/dist/runtime.js +185 -0
  71. package/dist/runtime.js.map +1 -0
  72. package/dist/session-getter.d.ts +30 -0
  73. package/dist/session-getter.d.ts.map +1 -0
  74. package/dist/session-getter.js +149 -0
  75. package/dist/session-getter.js.map +1 -0
  76. package/dist/types.d.ts +1580 -0
  77. package/dist/types.d.ts.map +1 -0
  78. package/dist/types.js +8 -0
  79. package/dist/types.js.map +1 -0
  80. package/dist/util.d.ts +9 -0
  81. package/dist/util.d.ts.map +1 -0
  82. package/dist/util.js +35 -0
  83. package/dist/util.js.map +1 -0
  84. package/dist/validate-client-metadata.d.ts +5 -0
  85. package/dist/validate-client-metadata.d.ts.map +1 -0
  86. package/dist/validate-client-metadata.js +46 -0
  87. package/dist/validate-client-metadata.js.map +1 -0
  88. package/package.json +46 -0
  89. package/src/constants.ts +4 -0
  90. package/src/fetch-dpop.ts +235 -0
  91. package/src/index.ts +18 -0
  92. package/src/lock.ts +34 -0
  93. package/src/oauth-agent.ts +150 -0
  94. package/src/oauth-authorization-server-metadata-resolver.ts +98 -0
  95. package/src/oauth-callback-error.ts +16 -0
  96. package/src/oauth-client.ts +440 -0
  97. package/src/oauth-protected-resource-metadata-resolver.ts +102 -0
  98. package/src/oauth-resolver-error.ts +12 -0
  99. package/src/oauth-resolver.ts +111 -0
  100. package/src/oauth-response-error.ts +31 -0
  101. package/src/oauth-server-agent.ts +275 -0
  102. package/src/oauth-server-factory.ts +41 -0
  103. package/src/refresh-error.ts +9 -0
  104. package/src/runtime-implementation.ts +17 -0
  105. package/src/runtime.ts +211 -0
  106. package/src/session-getter.ts +182 -0
  107. package/src/types.ts +26 -0
  108. package/src/util.ts +51 -0
  109. package/src/validate-client-metadata.ts +61 -0
  110. package/tsconfig.build.json +8 -0
  111. package/tsconfig.json +4 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clientMetadataSchema = void 0;
4
+ const oauth_types_1 = require("@atproto/oauth-types");
5
+ exports.clientMetadataSchema = oauth_types_1.oauthClientMetadataSchema.extend({
6
+ client_id: oauth_types_1.oauthClientIdSchema.url(),
7
+ });
8
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":";;;AAAA,sDAG6B;AAkBhB,QAAA,oBAAoB,GAAG,uCAAyB,CAAC,MAAM,CAAC;IACnE,SAAS,EAAE,iCAAmB,CAAC,GAAG,EAAE;CACrC,CAAC,CAAA"}
package/dist/util.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @todo (?) move to common package
3
+ */
4
+ export declare const withSignal: <T>(options: undefined | {
5
+ signal?: AbortSignal;
6
+ timeout: number;
7
+ }, fn: (signal: AbortSignal) => T | PromiseLike<T>) => Promise<T>;
8
+ export declare function contentMime(headers: Headers): string | undefined;
9
+ //# sourceMappingURL=util.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.d.ts","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,eAAO,MAAM,UAAU,eAEjB,SAAS,GACT;IACE,MAAM,CAAC,EAAE,WAAW,CAAA;IACpB,OAAO,EAAE,MAAM,CAAA;CAChB,MACD,CAAC,MAAM,EAAE,WAAW,KAAK,CAAC,GAAG,YAAY,CAAC,CAAC,KAC9C,QAAQ,CAAC,CAmCX,CAAA;AAED,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAEhE"}
package/dist/util.js ADDED
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.contentMime = exports.withSignal = void 0;
4
+ /**
5
+ * @todo (?) move to common package
6
+ */
7
+ const withSignal = async (options, fn) => {
8
+ options?.signal?.throwIfAborted();
9
+ const abortController = new AbortController();
10
+ const { signal } = abortController;
11
+ options?.signal?.addEventListener('abort', (reason) => abortController.abort(reason), { once: true, signal });
12
+ if (options?.timeout != null) {
13
+ const timeoutId = setTimeout((err) => abortController.abort(err), options.timeout, new Error('Timeout'));
14
+ timeoutId.unref?.(); // NodeJS only
15
+ signal.addEventListener('abort', () => clearTimeout(timeoutId), {
16
+ once: true,
17
+ signal,
18
+ });
19
+ }
20
+ try {
21
+ return await fn(signal);
22
+ }
23
+ finally {
24
+ // - Remove listener on incoming signal
25
+ // - Cancel timeout
26
+ // - Cancel pending (async) tasks
27
+ abortController.abort();
28
+ }
29
+ };
30
+ exports.withSignal = withSignal;
31
+ function contentMime(headers) {
32
+ return headers.get('content-type')?.split(';')[0].trim();
33
+ }
34
+ exports.contentMime = contentMime;
35
+ //# sourceMappingURL=util.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"util.js","sourceRoot":"","sources":["../src/util.ts"],"names":[],"mappings":";;;AAAA;;GAEG;AACI,MAAM,UAAU,GAAG,KAAK,EAC7B,OAKK,EACL,EAA+C,EACnC,EAAE;IACd,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,CAAA;IAEjC,MAAM,eAAe,GAAG,IAAI,eAAe,EAAE,CAAA;IAC7C,MAAM,EAAE,MAAM,EAAE,GAAG,eAAe,CAAA;IAElC,OAAO,EAAE,MAAM,EAAE,gBAAgB,CAC/B,OAAO,EACP,CAAC,MAAM,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,MAAM,CAAC,EACzC,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,CACvB,CAAA;IAED,IAAI,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,CAAC;QAC7B,MAAM,SAAS,GAAG,UAAU,CAC1B,CAAC,GAAG,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC,EACnC,OAAO,CAAC,OAAO,EACf,IAAI,KAAK,CAAC,SAAS,CAAC,CACrB,CAAA;QAED,SAAS,CAAC,KAAK,EAAE,EAAE,CAAA,CAAC,cAAc;QAElC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,YAAY,CAAC,SAAS,CAAC,EAAE;YAC9D,IAAI,EAAE,IAAI;YACV,MAAM;SACP,CAAC,CAAA;IACJ,CAAC;IAED,IAAI,CAAC;QACH,OAAO,MAAM,EAAE,CAAC,MAAM,CAAC,CAAA;IACzB,CAAC;YAAS,CAAC;QACT,uCAAuC;QACvC,mBAAmB;QACnB,iCAAiC;QACjC,eAAe,CAAC,KAAK,EAAE,CAAA;IACzB,CAAC;AACH,CAAC,CAAA;AA3CY,QAAA,UAAU,cA2CtB;AAED,SAAgB,WAAW,CAAC,OAAgB;IAC1C,OAAO,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAE,CAAC,IAAI,EAAE,CAAA;AAC3D,CAAC;AAFD,kCAEC"}
@@ -0,0 +1,5 @@
1
+ import { Keyset } from '@atproto/jwk';
2
+ import { OAuthClientMetadataInput } from '@atproto/oauth-types';
3
+ import { ClientMetadata } from './types.js';
4
+ export declare function validateClientMetadata(input: OAuthClientMetadataInput, keyset?: Keyset): ClientMetadata;
5
+ //# sourceMappingURL=validate-client-metadata.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-client-metadata.d.ts","sourceRoot":"","sources":["../src/validate-client-metadata.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AACrC,OAAO,EAEL,wBAAwB,EACzB,MAAM,sBAAsB,CAAA;AAE7B,OAAO,EAAE,cAAc,EAAwB,MAAM,YAAY,CAAA;AAQjE,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,wBAAwB,EAC/B,MAAM,CAAC,EAAE,MAAM,GACd,cAAc,CA2ChB"}
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateClientMetadata = void 0;
4
+ const oauth_types_1 = require("@atproto/oauth-types");
5
+ const types_js_1 = require("./types.js");
6
+ // Improve bundle size by using concatenation
7
+ const _ENDPOINT_AUTH_METHOD = '_endpoint_auth_method';
8
+ const _ENDPOINT_AUTH_SIGNING_ALG = '_endpoint_auth_signing_alg';
9
+ const TOKEN_ENDPOINT_AUTH_METHOD = `token${_ENDPOINT_AUTH_METHOD}`;
10
+ function validateClientMetadata(input, keyset) {
11
+ const metadata = types_js_1.clientMetadataSchema.parse(input);
12
+ // ATPROTO uses client metadata discovery
13
+ try {
14
+ new URL(metadata.client_id);
15
+ }
16
+ catch (cause) {
17
+ throw new TypeError(`client_id must be a valid URL`, { cause });
18
+ }
19
+ if (!metadata[TOKEN_ENDPOINT_AUTH_METHOD]) {
20
+ throw new TypeError(`${TOKEN_ENDPOINT_AUTH_METHOD} must be provided`);
21
+ }
22
+ for (const endpointName of oauth_types_1.OAUTH_AUTHENTICATED_ENDPOINT_NAMES) {
23
+ const method = metadata[`${endpointName}${_ENDPOINT_AUTH_METHOD}`];
24
+ switch (method) {
25
+ case undefined:
26
+ case 'none':
27
+ if (metadata[`${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG}`]) {
28
+ throw new TypeError(`${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG} must not be provided`);
29
+ }
30
+ break;
31
+ case 'client_secret_jwt':
32
+ if (!keyset) {
33
+ throw new TypeError(`Keyset is required for ${method} method`);
34
+ }
35
+ if (!metadata[`${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG}`]) {
36
+ throw new TypeError(`${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG} must be provided`);
37
+ }
38
+ break;
39
+ default:
40
+ throw new TypeError(`Invalid "${endpointName}${_ENDPOINT_AUTH_METHOD}" value: ${method}`);
41
+ }
42
+ }
43
+ return metadata;
44
+ }
45
+ exports.validateClientMetadata = validateClientMetadata;
46
+ //# sourceMappingURL=validate-client-metadata.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-client-metadata.js","sourceRoot":"","sources":["../src/validate-client-metadata.ts"],"names":[],"mappings":";;;AACA,sDAG6B;AAE7B,yCAAiE;AAEjE,6CAA6C;AAC7C,MAAM,qBAAqB,GAAG,uBAAuB,CAAA;AACrD,MAAM,0BAA0B,GAAG,4BAA4B,CAAA;AAE/D,MAAM,0BAA0B,GAAG,QAAQ,qBAAqB,EAAE,CAAA;AAElE,SAAgB,sBAAsB,CACpC,KAA+B,EAC/B,MAAe;IAEf,MAAM,QAAQ,GAAG,+BAAoB,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IAElD,yCAAyC;IACzC,IAAI,CAAC;QACH,IAAI,GAAG,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAA;IAC7B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,IAAI,SAAS,CAAC,+BAA+B,EAAE,EAAE,KAAK,EAAE,CAAC,CAAA;IACjE,CAAC;IAED,IAAI,CAAC,QAAQ,CAAC,0BAA0B,CAAC,EAAE,CAAC;QAC1C,MAAM,IAAI,SAAS,CAAC,GAAG,0BAA0B,mBAAmB,CAAC,CAAA;IACvE,CAAC;IAED,KAAK,MAAM,YAAY,IAAI,gDAAkC,EAAE,CAAC;QAC9D,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,YAAY,GAAG,qBAAqB,EAAE,CAAC,CAAA;QAClE,QAAQ,MAAM,EAAE,CAAC;YACf,KAAK,SAAS,CAAC;YACf,KAAK,MAAM;gBACT,IAAI,QAAQ,CAAC,GAAG,YAAY,GAAG,0BAA0B,EAAE,CAAC,EAAE,CAAC;oBAC7D,MAAM,IAAI,SAAS,CACjB,GAAG,YAAY,GAAG,0BAA0B,uBAAuB,CACpE,CAAA;gBACH,CAAC;gBACD,MAAK;YACP,KAAK,mBAAmB;gBACtB,IAAI,CAAC,MAAM,EAAE,CAAC;oBACZ,MAAM,IAAI,SAAS,CAAC,0BAA0B,MAAM,SAAS,CAAC,CAAA;gBAChE,CAAC;gBACD,IAAI,CAAC,QAAQ,CAAC,GAAG,YAAY,GAAG,0BAA0B,EAAE,CAAC,EAAE,CAAC;oBAC9D,MAAM,IAAI,SAAS,CACjB,GAAG,YAAY,GAAG,0BAA0B,mBAAmB,CAChE,CAAA;gBACH,CAAC;gBACD,MAAK;YACP;gBACE,MAAM,IAAI,SAAS,CACjB,YAAY,YAAY,GAAG,qBAAqB,YAAY,MAAM,EAAE,CACrE,CAAA;QACL,CAAC;IACH,CAAC;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AA9CD,wDA8CC"}
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@atproto/oauth-client",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).",
6
+ "keywords": [
7
+ "atproto",
8
+ "oauth",
9
+ "client",
10
+ "isomorphic"
11
+ ],
12
+ "homepage": "https://atproto.com",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "https://github.com/bluesky-social/atproto",
16
+ "directory": "packages/oauth/oauth-client"
17
+ },
18
+ "type": "commonjs",
19
+ "main": "dist/index.js",
20
+ "types": "dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "dependencies": {
28
+ "multiformats": "^9.9.0",
29
+ "zod": "^3.23.8",
30
+ "@atproto-labs/did-resolver": "0.1.0",
31
+ "@atproto-labs/fetch": "0.1.0",
32
+ "@atproto-labs/handle-resolver": "0.1.0",
33
+ "@atproto-labs/identity-resolver": "0.1.0",
34
+ "@atproto-labs/simple-store": "0.1.0",
35
+ "@atproto-labs/simple-store-memory": "0.1.0",
36
+ "@atproto/did": "0.1.0",
37
+ "@atproto/jwk": "0.1.0",
38
+ "@atproto/oauth-types": "0.1.0"
39
+ },
40
+ "devDependencies": {
41
+ "typescript": "^5.3.3"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc --build tsconfig.build.json"
45
+ }
46
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Per ATProto spec (OpenID uses RS256)
3
+ */
4
+ export const FALLBACK_ALG = 'ES256'
@@ -0,0 +1,235 @@
1
+ import { Fetch, FetchContext, cancelBody, peekJson } from '@atproto-labs/fetch'
2
+ import { SimpleStore } from '@atproto-labs/simple-store'
3
+ import { Key } from '@atproto/jwk'
4
+ import { base64url } from 'multiformats/bases/base64'
5
+
6
+ // "undefined" in non https environments or environments without crypto
7
+ const subtle = globalThis.crypto?.subtle as SubtleCrypto | undefined
8
+
9
+ const ReadableStream = globalThis.ReadableStream as
10
+ | typeof globalThis.ReadableStream
11
+ | undefined
12
+
13
+ export type DpopFetchWrapperOptions<C = FetchContext> = {
14
+ key: Key
15
+ iss: string
16
+ nonces: SimpleStore<string, string>
17
+ supportedAlgs?: string[]
18
+ sha256?: (input: string) => Promise<string>
19
+
20
+ /**
21
+ * Is the intended server an authorization server (true) or a resource server
22
+ * (false)? Setting this may allow to avoid parsing the response body to
23
+ * determine the dpop-nonce.
24
+ *
25
+ * @default undefined
26
+ */
27
+ isAuthServer?: boolean
28
+ fetch?: Fetch<C>
29
+ }
30
+
31
+ export function dpopFetchWrapper<C = FetchContext>({
32
+ key,
33
+ iss,
34
+ supportedAlgs,
35
+ nonces,
36
+ sha256 = typeof subtle !== 'undefined' ? subtleSha256 : undefined,
37
+ isAuthServer,
38
+ fetch = globalThis.fetch,
39
+ }: DpopFetchWrapperOptions<C>): Fetch<C> {
40
+ if (!sha256) {
41
+ throw new TypeError(
42
+ `crypto.subtle is not available in this environment. Please provide a sha256 function.`,
43
+ )
44
+ }
45
+
46
+ const alg = negotiateAlg(key, supportedAlgs)
47
+
48
+ return async function (this: C, input, init) {
49
+ if (!key.algorithms.includes(alg)) {
50
+ throw new TypeError(`Key does not support the algorithm ${alg}`)
51
+ }
52
+
53
+ const request: Request =
54
+ init == null && input instanceof Request
55
+ ? input
56
+ : new Request(input, init)
57
+
58
+ const authorizationHeader = request.headers.get('Authorization')
59
+ const ath = authorizationHeader?.startsWith('DPoP ')
60
+ ? await sha256(authorizationHeader.slice(5))
61
+ : undefined
62
+
63
+ const { method, url } = request
64
+ const { origin } = new URL(url)
65
+
66
+ let initNonce: string | undefined
67
+ try {
68
+ initNonce = await nonces.get(origin)
69
+ } catch {
70
+ // Ignore get errors, we will just not send a nonce
71
+ }
72
+
73
+ const initProof = await buildProof(
74
+ key,
75
+ alg,
76
+ iss,
77
+ method,
78
+ url,
79
+ initNonce,
80
+ ath,
81
+ )
82
+ request.headers.set('DPoP', initProof)
83
+
84
+ const initResponse = await fetch.call(this, request)
85
+
86
+ // Make sure the response body is consumed. Either by the caller (when the
87
+ // response is returned), of if an error is thrown (catch block).
88
+
89
+ const nextNonce = initResponse.headers.get('DPoP-Nonce')
90
+ if (!nextNonce || nextNonce === initNonce) {
91
+ // No nonce was returned or it is the same as the one we sent. No need to
92
+ // update the nonce store, or retry the request.
93
+ return initResponse
94
+ }
95
+
96
+ // Store the fresh nonce for future requests
97
+ try {
98
+ await nonces.set(origin, nextNonce)
99
+ } catch {
100
+ // Ignore set errors
101
+ }
102
+
103
+ const shouldRetry = await isUseDpopNonceError(initResponse, isAuthServer)
104
+ if (!shouldRetry) {
105
+ // Not a "use_dpop_nonce" error, so there is no need to retry
106
+ return initResponse
107
+ }
108
+
109
+ // If the input stream was already consumed, we cannot retry the request. A
110
+ // solution would be to clone() the request but that would bufferize the
111
+ // entire stream in memory which can lead to memory starvation. Instead, we
112
+ // will return the original response and let the calling code handle retries.
113
+
114
+ if (input === request) {
115
+ // The input request body was consumed. We cannot retry the request.
116
+ return initResponse
117
+ }
118
+
119
+ if (ReadableStream && init?.body instanceof ReadableStream) {
120
+ // The init body was consumed. We cannot retry the request.
121
+ return initResponse
122
+ }
123
+
124
+ // We will now retry the request with the fresh nonce.
125
+
126
+ // The initial response body must be consumed (see cancelBody's doc).
127
+ await cancelBody(initResponse, 'log')
128
+
129
+ const nextProof = await buildProof(
130
+ key,
131
+ alg,
132
+ iss,
133
+ method,
134
+ url,
135
+ nextNonce,
136
+ ath,
137
+ )
138
+ const nextRequest = new Request(input, init)
139
+ nextRequest.headers.set('DPoP', nextProof)
140
+
141
+ return fetch.call(this, nextRequest)
142
+ }
143
+ }
144
+
145
+ async function buildProof(
146
+ key: Key,
147
+ alg: string,
148
+ iss: string,
149
+ htm: string,
150
+ htu: string,
151
+ nonce?: string,
152
+ ath?: string,
153
+ ) {
154
+ if (!key.bareJwk) {
155
+ throw new Error('Only asymmetric keys can be used as DPoP proofs')
156
+ }
157
+
158
+ const now = Math.floor(Date.now() / 1e3)
159
+
160
+ return key.createJwt(
161
+ {
162
+ alg,
163
+ typ: 'dpop+jwt',
164
+ jwk: key.bareJwk,
165
+ },
166
+ {
167
+ iss,
168
+ iat: now,
169
+ exp: now + 10,
170
+ // Any collision will cause the request to be rejected by the server. no biggie.
171
+ jti: Math.random().toString(36).slice(2),
172
+ htm,
173
+ htu,
174
+ nonce,
175
+ ath,
176
+ },
177
+ )
178
+ }
179
+
180
+ async function isUseDpopNonceError(
181
+ response: Response,
182
+ isAuthServer?: boolean,
183
+ ): Promise<boolean> {
184
+ // https://datatracker.ietf.org/doc/html/rfc6750#section-3
185
+ // https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no
186
+ if (isAuthServer === undefined || isAuthServer === false) {
187
+ if (response.status === 401) {
188
+ const wwwAuth = response.headers.get('WWW-Authenticate')
189
+ if (wwwAuth?.startsWith('DPoP')) {
190
+ return wwwAuth.includes('error="use_dpop_nonce"')
191
+ }
192
+ }
193
+ }
194
+
195
+ // https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid
196
+ if (isAuthServer === undefined || isAuthServer === true) {
197
+ if (response.status === 400) {
198
+ try {
199
+ const json = await peekJson(response, 10 * 1024)
200
+ return typeof json === 'object' && json?.['error'] === 'use_dpop_nonce'
201
+ } catch {
202
+ // Response too big (to be "use_dpop_nonce" error) or invalid JSON
203
+ return false
204
+ }
205
+ }
206
+ }
207
+
208
+ return false
209
+ }
210
+
211
+ function negotiateAlg(key: Key, supportedAlgs: string[] | undefined): string {
212
+ if (supportedAlgs) {
213
+ // Use order of supportedAlgs as preference
214
+ const alg = supportedAlgs.find((a) => key.algorithms.includes(a))
215
+ if (alg) return alg
216
+ } else {
217
+ const [alg] = key.algorithms
218
+ if (alg) return alg
219
+ }
220
+
221
+ throw new Error('Key does not match any alg supported by the server')
222
+ }
223
+
224
+ async function subtleSha256(input: string): Promise<string> {
225
+ if (subtle == null) {
226
+ throw new Error(
227
+ `crypto.subtle is not available in this environment. Please provide a sha256 function.`,
228
+ )
229
+ }
230
+
231
+ const bytes = new TextEncoder().encode(input)
232
+ const digest = await subtle.digest('SHA-256', bytes)
233
+ const digestBytes = new Uint8Array(digest)
234
+ return base64url.baseEncode(digestBytes)
235
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export {
2
+ FetchError,
3
+ FetchRequestError,
4
+ FetchResponseError,
5
+ } from '@atproto-labs/fetch'
6
+ export * from './oauth-agent.js'
7
+ export * from './oauth-authorization-server-metadata-resolver.js'
8
+ export * from './oauth-callback-error.js'
9
+ export * from './oauth-client.js'
10
+ export * from './oauth-protected-resource-metadata-resolver.js'
11
+ export * from './oauth-resolver-error.js'
12
+ export * from './oauth-response-error.js'
13
+ export * from './oauth-server-agent.js'
14
+ export * from './oauth-server-factory.js'
15
+ export * from './refresh-error.js'
16
+ export * from './runtime-implementation.js'
17
+ export * from './session-getter.js'
18
+ export * from './types.js'
package/src/lock.ts ADDED
@@ -0,0 +1,34 @@
1
+ const locks = new Map<unknown, Promise<void>>()
2
+
3
+ function acquireLocalLock(name: unknown): Promise<() => void> {
4
+ return new Promise((resolveAcquire) => {
5
+ const prev = locks.get(name) ?? Promise.resolve()
6
+ const next = prev.then(() => {
7
+ return new Promise<void>((resolveRelease) => {
8
+ const release = () => {
9
+ // Only delete the lock if it is still the current one
10
+ if (locks.get(name) === next) locks.delete(name)
11
+
12
+ resolveRelease()
13
+ }
14
+
15
+ resolveAcquire(release)
16
+ })
17
+ })
18
+
19
+ locks.set(name, next)
20
+ })
21
+ }
22
+
23
+ export function requestLocalLock<T>(
24
+ name: string,
25
+ fn: () => T | PromiseLike<T>,
26
+ ): Promise<T> {
27
+ return acquireLocalLock(name).then(async (release) => {
28
+ try {
29
+ return await fn()
30
+ } finally {
31
+ release()
32
+ }
33
+ })
34
+ }
@@ -0,0 +1,150 @@
1
+ import { Fetch, bindFetch } from '@atproto-labs/fetch'
2
+ import { JwtPayload, unsafeDecodeJwt } from '@atproto/jwk'
3
+ import { OAuthAuthorizationServerMetadata } from '@atproto/oauth-types'
4
+
5
+ import { dpopFetchWrapper } from './fetch-dpop.js'
6
+ import { OAuthServerAgent, TokenSet } from './oauth-server-agent.js'
7
+ import { SessionGetter } from './session-getter.js'
8
+
9
+ const ReadableStream = globalThis.ReadableStream as
10
+ | typeof globalThis.ReadableStream
11
+ | undefined
12
+
13
+ export class OAuthAgent {
14
+ protected dpopFetch: Fetch<unknown>
15
+
16
+ constructor(
17
+ public readonly server: OAuthServerAgent,
18
+ public readonly sub: string,
19
+ private readonly sessionGetter: SessionGetter,
20
+ fetch: Fetch = globalThis.fetch,
21
+ ) {
22
+ this.dpopFetch = dpopFetchWrapper<void>({
23
+ fetch: bindFetch(fetch),
24
+ iss: server.clientMetadata.client_id,
25
+ key: server.dpopKey,
26
+ supportedAlgs: server.serverMetadata.dpop_signing_alg_values_supported,
27
+ sha256: async (v) => server.runtime.sha256(v),
28
+ nonces: server.dpopNonces,
29
+ isAuthServer: false,
30
+ })
31
+ }
32
+
33
+ get serverMetadata(): Readonly<OAuthAuthorizationServerMetadata> {
34
+ return this.server.serverMetadata
35
+ }
36
+
37
+ public async refreshIfNeeded(): Promise<void> {
38
+ await this.getTokenSet(undefined)
39
+ }
40
+
41
+ /**
42
+ * @param refresh See {@link SessionGetter.getSession}
43
+ */
44
+ protected async getTokenSet(refresh?: boolean): Promise<TokenSet> {
45
+ const { tokenSet } = await this.sessionGetter.getSession(this.sub, refresh)
46
+ return tokenSet
47
+ }
48
+
49
+ async getInfo(): Promise<{
50
+ userinfo?: JwtPayload
51
+ expired?: boolean
52
+ scope?: string
53
+ iss: string
54
+ aud: string
55
+ sub: string
56
+ }> {
57
+ const tokenSet = await this.getTokenSet()
58
+
59
+ return {
60
+ userinfo: tokenSet.id_token
61
+ ? unsafeDecodeJwt(tokenSet.id_token).payload
62
+ : undefined,
63
+ expired:
64
+ tokenSet.expires_at == null
65
+ ? undefined
66
+ : new Date(tokenSet.expires_at).getTime() < Date.now() - 5e3,
67
+ scope: tokenSet.scope,
68
+ iss: tokenSet.iss,
69
+ aud: tokenSet.aud,
70
+ sub: tokenSet.sub,
71
+ }
72
+ }
73
+
74
+ async signOut(): Promise<void> {
75
+ try {
76
+ const { tokenSet } = await this.sessionGetter.getSession(this.sub, false)
77
+ await this.server.revoke(tokenSet.access_token)
78
+ } finally {
79
+ await this.sessionGetter.delStored(this.sub)
80
+ }
81
+ }
82
+
83
+ async request(pathname: string, init?: RequestInit): Promise<Response> {
84
+ // This will try and refresh the token if it is known to be expired
85
+ const tokenSet = await this.getTokenSet(undefined)
86
+
87
+ const initialUrl = new URL(pathname, tokenSet.aud)
88
+ const initialAuth = `${tokenSet.token_type} ${tokenSet.access_token}`
89
+
90
+ const headers = new Headers(init?.headers)
91
+ headers.set('Authorization', initialAuth)
92
+
93
+ const initialResponse = await this.dpopFetch(initialUrl, {
94
+ ...init,
95
+ headers,
96
+ })
97
+
98
+ // If the token is not expired, we don't need to refresh it
99
+ if (!isTokenExpiredResponse(initialResponse)) {
100
+ return initialResponse
101
+ }
102
+
103
+ let tokenSetFresh: TokenSet
104
+ try {
105
+ // "true" here will cause the token to be refreshed
106
+ tokenSetFresh = await this.getTokenSet(true)
107
+ } catch (err) {
108
+ return initialResponse
109
+ }
110
+
111
+ // The stream was already consumed. We cannot retry the request. A solution
112
+ // would be to tee() the input stream but that would bufferize the entire
113
+ // stream in memory which can lead to memory starvation. Instead, we will
114
+ // return the original response and let the calling code handle retries.
115
+ if (ReadableStream && init?.body instanceof ReadableStream) {
116
+ return initialResponse
117
+ }
118
+
119
+ const finalAuth = `${tokenSetFresh.token_type} ${tokenSetFresh.access_token}`
120
+ const finalUrl = new URL(pathname, tokenSetFresh.aud)
121
+
122
+ headers.set('Authorization', finalAuth)
123
+
124
+ const finalResponse = await this.dpopFetch(finalUrl, { ...init, headers })
125
+
126
+ // There is no need to keep the session in the store if the token is expired
127
+ // and there is no way to refresh it.
128
+ if (isTokenExpiredResponse(finalResponse)) {
129
+ // TODO: Is there a "softer" way to handle this, e.g. by marking the
130
+ // session as "expired" and allow the user to trigger a new login?
131
+ await this.sessionGetter.delStored(this.sub)
132
+ }
133
+
134
+ return finalResponse
135
+ }
136
+ }
137
+
138
+ /**
139
+ * @see {@link https://datatracker.ietf.org/doc/html/rfc6750#section-3}
140
+ * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no}
141
+ */
142
+ function isTokenExpiredResponse(response: Response) {
143
+ if (response.status !== 401) return false
144
+ const wwwAuth = response.headers.get('WWW-Authenticate')
145
+ return (
146
+ wwwAuth != null &&
147
+ (wwwAuth.startsWith('Bearer ') || wwwAuth.startsWith('DPoP ')) &&
148
+ wwwAuth.includes('error="invalid_token"')
149
+ )
150
+ }