@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,182 @@
1
+ import {
2
+ CachedGetter,
3
+ GetCachedOptions,
4
+ SimpleStore,
5
+ } from '@atproto-labs/simple-store'
6
+ import { Key } from '@atproto/jwk'
7
+ import { OAuthResponseError } from './oauth-response-error.js'
8
+ import { TokenSet } from './oauth-server-agent.js'
9
+ import { OAuthServerFactory } from './oauth-server-factory.js'
10
+ import { RefreshError } from './refresh-error.js'
11
+ import { Runtime } from './runtime.js'
12
+ import { withSignal } from './util.js'
13
+
14
+ export type Session = {
15
+ dpopKey: Key
16
+ tokenSet: TokenSet
17
+ }
18
+
19
+ export type SessionStore = SimpleStore<string, Session>
20
+
21
+ /**
22
+ * There are several advantages to wrapping the sessionStore in a (single)
23
+ * CachedGetter, the main of which is that the cached getter will ensure that at
24
+ * most one fresh call is ever being made. Another advantage, is that it
25
+ * contains the logic for reading from the cache which, if the cache is based on
26
+ * localStorage/indexedDB, will sync across multiple tabs (for a given sub).
27
+ */
28
+ export class SessionGetter extends CachedGetter<string, Session> {
29
+ constructor(
30
+ sessionStore: SessionStore,
31
+ serverFactory: OAuthServerFactory,
32
+ private readonly runtime: Runtime,
33
+ ) {
34
+ super(
35
+ async (sub, options, storedSession) => {
36
+ // There needs to be a previous session to be able to refresh. If
37
+ // storedSession is undefined, it means that the store does not contain
38
+ // a session for the given sub. Since this might have been caused by the
39
+ // value being cleared in another process (e.g. another tab), we will
40
+ // give a chance to the process running this code to detect that the
41
+ // session was revoked. This should allow processes not implementing a
42
+ // subscribe/notify between instances to still be "notified" that the
43
+ // session was revoked.
44
+ if (storedSession === undefined) {
45
+ // Because the session is not in the store, the sessionStore.del
46
+ // function will not be called, even if the "deleteOnError" callback
47
+ // returns true when the error is an "OAuthRefreshError". Let's
48
+ // call it here manually.
49
+ await sessionStore.del(sub)
50
+ throw new RefreshError(sub, 'The session was revoked')
51
+ }
52
+
53
+ if (sub !== storedSession.tokenSet.sub) {
54
+ // Fool-proofing (e.g. against invalid session storage)
55
+ throw new RefreshError(sub, 'Stored session sub mismatch')
56
+ }
57
+
58
+ // Since refresh tokens can only be used once, we might run into
59
+ // concurrency issues if multiple tabs/instances are trying to refresh
60
+ // the same token. The chances of this happening when multiple instances
61
+ // are started simultaneously is reduced by randomizing the expiry time
62
+ // (see isStale() bellow). Even so, There still exist chances that
63
+ // multiple tabs will try to refresh the token at the same time. The
64
+ // best solution would be to use a mutex/lock to ensure that only one
65
+ // instance is refreshing the token at a time. A simpler workaround is
66
+ // to check if the value stored in the session store is the same as the
67
+ // one in memory. If it isn't, then another instance has already
68
+ // refreshed the token.
69
+
70
+ const { tokenSet, dpopKey } = storedSession
71
+ const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
72
+
73
+ // We must not use the "signal" to cancel the refresh or its storage in
74
+ // case of successful refresh. If we obtain a new refresh token, we must
75
+ // ensure that is gets stored in the session store (by returning the new
76
+ // session object). Failing to do so would result in the new credentials
77
+ // being lost.
78
+ options?.signal?.throwIfAborted()
79
+
80
+ const newTokenSet = await server
81
+ .refresh(tokenSet)
82
+ .catch(async (cause) => {
83
+ if (
84
+ cause instanceof OAuthResponseError &&
85
+ cause.status === 400 &&
86
+ cause.error === 'invalid_grant'
87
+ ) {
88
+ // In case there is no lock implementation in the runtime, we will
89
+ // wait for a short time to give the other concurrent instances a
90
+ // chance to finish their refreshing of the token. If a concurrent
91
+ // refresh did occur, we will pretend that this one succeeded.
92
+ if (!runtime.hasLock) {
93
+ await new Promise((r) => setTimeout(r, 1000))
94
+
95
+ const stored = await this.getStored(sub)
96
+ if (stored === undefined) {
97
+ // Using a distinct error message mainly for debugging
98
+ // purposes
99
+ const msg = 'The session was revoked by another process'
100
+ throw new RefreshError(sub, msg, { cause })
101
+ } else if (
102
+ stored.tokenSet.access_token !== tokenSet.access_token ||
103
+ stored.tokenSet.refresh_token !== tokenSet.refresh_token
104
+ ) {
105
+ // A concurrent refresh occurred. Pretend this one succeeded.
106
+ return stored.tokenSet
107
+ } else {
108
+ // There were no concurrent refresh. The token is (likely)
109
+ // simply no longer valid.
110
+ }
111
+ }
112
+
113
+ // Throwing an RefreshError to trigger deletion through the
114
+ // deleteOnError callback.
115
+ const msg = cause.errorDescription ?? 'The session was revoked'
116
+ throw new RefreshError(sub, msg, { cause })
117
+ }
118
+
119
+ throw cause
120
+ })
121
+
122
+ if (sub !== newTokenSet.sub) {
123
+ // The server returned another sub. Was the tokenSet manipulated?
124
+ throw new RefreshError(sub, 'Token set sub mismatch')
125
+ }
126
+
127
+ return { ...storedSession, tokenSet: newTokenSet }
128
+ },
129
+ sessionStore,
130
+ {
131
+ isStale: (sub, { tokenSet }) => {
132
+ return (
133
+ tokenSet.expires_at != null &&
134
+ new Date(tokenSet.expires_at).getTime() <
135
+ // Add some lee way to ensure the token is not expired when it
136
+ // reaches the server.
137
+ Date.now() + 60e3
138
+ )
139
+ },
140
+ onStoreError: async (err, sub, { tokenSet, dpopKey }) => {
141
+ // If the token data cannot be stored, let's revoke it
142
+ const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
143
+ await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
144
+ throw err
145
+ },
146
+ deleteOnError: async (err) => {
147
+ return err instanceof RefreshError
148
+ },
149
+ },
150
+ )
151
+ }
152
+
153
+ /**
154
+ * @param refresh When `true`, the credentials will be refreshed even if they
155
+ * are not expired. When `false`, the credentials will not be refreshed even
156
+ * if they are expired. When `undefined`, the credentials will be refreshed
157
+ * if, and only if, they are (about to be) expired. Defaults to `undefined`.
158
+ */
159
+ async getSession(sub: string, refresh?: boolean) {
160
+ const session = await this.get(sub, {
161
+ noCache: refresh === true,
162
+ allowStale: refresh === false,
163
+ })
164
+
165
+ if (sub !== session.tokenSet.sub) {
166
+ // Fool-proofing (e.g. against invalid session storage)
167
+ throw new Error('Token set does not match the expected sub')
168
+ }
169
+
170
+ return session
171
+ }
172
+
173
+ async get(sub: string, options?: GetCachedOptions): Promise<Session> {
174
+ return this.runtime.withLock(`@atproto-oauth-client-${sub}`, async () => {
175
+ // Make sure, even if there is no signal in the options, that the request
176
+ // will be cancelled after at most 30 seconds.
177
+ return withSignal({ signal: options?.signal, timeout: 30e3 }, (signal) =>
178
+ super.get(sub, { ...options, signal }),
179
+ )
180
+ })
181
+ }
182
+ }
package/src/types.ts ADDED
@@ -0,0 +1,26 @@
1
+ import {
2
+ oauthClientIdSchema,
3
+ oauthClientMetadataSchema,
4
+ } from '@atproto/oauth-types'
5
+ import z from 'zod'
6
+
7
+ // Note: These types are not prefixed with `OAuth` because they are not specific
8
+ // to OAuth. They are specific to this packages. OAuth specific types are in
9
+ // `@atproto/oauth-types`.
10
+
11
+ export type AuthorizeOptions = {
12
+ display?: 'page' | 'popup' | 'touch' | 'wap'
13
+ redirect_uri?: string
14
+ id_token_hint?: string
15
+ max_age?: number
16
+ prompt?: 'login' | 'none' | 'consent' | 'select_account'
17
+ scope?: string
18
+ state?: string
19
+ ui_locales?: string
20
+ }
21
+
22
+ export const clientMetadataSchema = oauthClientMetadataSchema.extend({
23
+ client_id: oauthClientIdSchema.url(),
24
+ })
25
+
26
+ export type ClientMetadata = z.infer<typeof clientMetadataSchema>
package/src/util.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @todo (?) move to common package
3
+ */
4
+ export const withSignal = async <T>(
5
+ options:
6
+ | undefined
7
+ | {
8
+ signal?: AbortSignal
9
+ timeout: number
10
+ },
11
+ fn: (signal: AbortSignal) => T | PromiseLike<T>,
12
+ ): Promise<T> => {
13
+ options?.signal?.throwIfAborted()
14
+
15
+ const abortController = new AbortController()
16
+ const { signal } = abortController
17
+
18
+ options?.signal?.addEventListener(
19
+ 'abort',
20
+ (reason) => abortController.abort(reason),
21
+ { once: true, signal },
22
+ )
23
+
24
+ if (options?.timeout != null) {
25
+ const timeoutId = setTimeout(
26
+ (err) => abortController.abort(err),
27
+ options.timeout,
28
+ new Error('Timeout'),
29
+ )
30
+
31
+ timeoutId.unref?.() // NodeJS only
32
+
33
+ signal.addEventListener('abort', () => clearTimeout(timeoutId), {
34
+ once: true,
35
+ signal,
36
+ })
37
+ }
38
+
39
+ try {
40
+ return await fn(signal)
41
+ } finally {
42
+ // - Remove listener on incoming signal
43
+ // - Cancel timeout
44
+ // - Cancel pending (async) tasks
45
+ abortController.abort()
46
+ }
47
+ }
48
+
49
+ export function contentMime(headers: Headers): string | undefined {
50
+ return headers.get('content-type')?.split(';')[0]!.trim()
51
+ }
@@ -0,0 +1,61 @@
1
+ import { Keyset } from '@atproto/jwk'
2
+ import {
3
+ OAUTH_AUTHENTICATED_ENDPOINT_NAMES,
4
+ OAuthClientMetadataInput,
5
+ } from '@atproto/oauth-types'
6
+
7
+ import { ClientMetadata, clientMetadataSchema } from './types.js'
8
+
9
+ // Improve bundle size by using concatenation
10
+ const _ENDPOINT_AUTH_METHOD = '_endpoint_auth_method'
11
+ const _ENDPOINT_AUTH_SIGNING_ALG = '_endpoint_auth_signing_alg'
12
+
13
+ const TOKEN_ENDPOINT_AUTH_METHOD = `token${_ENDPOINT_AUTH_METHOD}`
14
+
15
+ export function validateClientMetadata(
16
+ input: OAuthClientMetadataInput,
17
+ keyset?: Keyset,
18
+ ): ClientMetadata {
19
+ const metadata = clientMetadataSchema.parse(input)
20
+
21
+ // ATPROTO uses client metadata discovery
22
+ try {
23
+ new URL(metadata.client_id)
24
+ } catch (cause) {
25
+ throw new TypeError(`client_id must be a valid URL`, { cause })
26
+ }
27
+
28
+ if (!metadata[TOKEN_ENDPOINT_AUTH_METHOD]) {
29
+ throw new TypeError(`${TOKEN_ENDPOINT_AUTH_METHOD} must be provided`)
30
+ }
31
+
32
+ for (const endpointName of OAUTH_AUTHENTICATED_ENDPOINT_NAMES) {
33
+ const method = metadata[`${endpointName}${_ENDPOINT_AUTH_METHOD}`]
34
+ switch (method) {
35
+ case undefined:
36
+ case 'none':
37
+ if (metadata[`${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG}`]) {
38
+ throw new TypeError(
39
+ `${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG} must not be provided`,
40
+ )
41
+ }
42
+ break
43
+ case 'client_secret_jwt':
44
+ if (!keyset) {
45
+ throw new TypeError(`Keyset is required for ${method} method`)
46
+ }
47
+ if (!metadata[`${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG}`]) {
48
+ throw new TypeError(
49
+ `${endpointName}${_ENDPOINT_AUTH_SIGNING_ALG} must be provided`,
50
+ )
51
+ }
52
+ break
53
+ default:
54
+ throw new TypeError(
55
+ `Invalid "${endpointName}${_ENDPOINT_AUTH_METHOD}" value: ${method}`,
56
+ )
57
+ }
58
+ }
59
+
60
+ return metadata
61
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["../../../tsconfig/isomorphic.json"],
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist"
6
+ },
7
+ "include": ["./src"]
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "include": [],
3
+ "references": [{ "path": "./tsconfig.build.json" }]
4
+ }