@atproto/oauth-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/LICENSE.txt +7 -0
- package/README.md +124 -0
- package/dist/constants.d.ts +5 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +8 -0
- package/dist/constants.js.map +1 -0
- package/dist/fetch-dpop.d.ts +21 -0
- package/dist/fetch-dpop.d.ts.map +1 -0
- package/dist/fetch-dpop.js +149 -0
- package/dist/fetch-dpop.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/index.js.map +1 -0
- package/dist/lock.d.ts +2 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/lock.js +33 -0
- package/dist/lock.js.map +1 -0
- package/dist/oauth-agent.d.ts +29 -0
- package/dist/oauth-agent.d.ts.map +1 -0
- package/dist/oauth-agent.js +138 -0
- package/dist/oauth-agent.js.map +1 -0
- package/dist/oauth-authorization-server-metadata-resolver.d.ts +15 -0
- package/dist/oauth-authorization-server-metadata-resolver.d.ts.map +1 -0
- package/dist/oauth-authorization-server-metadata-resolver.js +56 -0
- package/dist/oauth-authorization-server-metadata-resolver.js.map +1 -0
- package/dist/oauth-callback-error.d.ts +7 -0
- package/dist/oauth-callback-error.d.ts.map +1 -0
- package/dist/oauth-callback-error.js +28 -0
- package/dist/oauth-callback-error.js.map +1 -0
- package/dist/oauth-client.d.ts +78 -0
- package/dist/oauth-client.d.ts.map +1 -0
- package/dist/oauth-client.js +278 -0
- package/dist/oauth-client.js.map +1 -0
- package/dist/oauth-protected-resource-metadata-resolver.d.ts +15 -0
- package/dist/oauth-protected-resource-metadata-resolver.d.ts.map +1 -0
- package/dist/oauth-protected-resource-metadata-resolver.js +58 -0
- package/dist/oauth-protected-resource-metadata-resolver.js.map +1 -0
- package/dist/oauth-resolver-error.d.ts +7 -0
- package/dist/oauth-resolver-error.d.ts.map +1 -0
- package/dist/oauth-resolver-error.js +17 -0
- package/dist/oauth-resolver-error.js.map +1 -0
- package/dist/oauth-resolver.d.ts +62 -0
- package/dist/oauth-resolver.d.ts.map +1 -0
- package/dist/oauth-resolver.js +73 -0
- package/dist/oauth-resolver.js.map +1 -0
- package/dist/oauth-response-error.d.ts +11 -0
- package/dist/oauth-response-error.d.ts.map +1 -0
- package/dist/oauth-response-error.js +48 -0
- package/dist/oauth-response-error.js.map +1 -0
- package/dist/oauth-server-agent.d.ts +51 -0
- package/dist/oauth-server-agent.d.ts.map +1 -0
- package/dist/oauth-server-agent.js +228 -0
- package/dist/oauth-server-agent.js.map +1 -0
- package/dist/oauth-server-factory.d.ts +20 -0
- package/dist/oauth-server-factory.d.ts.map +1 -0
- package/dist/oauth-server-factory.js +53 -0
- package/dist/oauth-server-factory.js.map +1 -0
- package/dist/refresh-error.d.ts +7 -0
- package/dist/refresh-error.d.ts.map +1 -0
- package/dist/refresh-error.js +16 -0
- package/dist/refresh-error.js.map +1 -0
- package/dist/runtime-implementation.d.ts +12 -0
- package/dist/runtime-implementation.d.ts.map +1 -0
- package/dist/runtime-implementation.js +3 -0
- package/dist/runtime-implementation.js.map +1 -0
- package/dist/runtime.d.ts +35 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +185 -0
- package/dist/runtime.js.map +1 -0
- package/dist/session-getter.d.ts +30 -0
- package/dist/session-getter.d.ts.map +1 -0
- package/dist/session-getter.js +149 -0
- package/dist/session-getter.js.map +1 -0
- package/dist/types.d.ts +1580 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +9 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +35 -0
- package/dist/util.js.map +1 -0
- package/dist/validate-client-metadata.d.ts +5 -0
- package/dist/validate-client-metadata.d.ts.map +1 -0
- package/dist/validate-client-metadata.js +46 -0
- package/dist/validate-client-metadata.js.map +1 -0
- package/package.json +46 -0
- package/src/constants.ts +4 -0
- package/src/fetch-dpop.ts +235 -0
- package/src/index.ts +18 -0
- package/src/lock.ts +34 -0
- package/src/oauth-agent.ts +150 -0
- package/src/oauth-authorization-server-metadata-resolver.ts +98 -0
- package/src/oauth-callback-error.ts +16 -0
- package/src/oauth-client.ts +440 -0
- package/src/oauth-protected-resource-metadata-resolver.ts +102 -0
- package/src/oauth-resolver-error.ts +12 -0
- package/src/oauth-resolver.ts +111 -0
- package/src/oauth-response-error.ts +31 -0
- package/src/oauth-server-agent.ts +275 -0
- package/src/oauth-server-factory.ts +41 -0
- package/src/refresh-error.ts +9 -0
- package/src/runtime-implementation.ts +17 -0
- package/src/runtime.ts +211 -0
- package/src/session-getter.ts +182 -0
- package/src/types.ts +26 -0
- package/src/util.ts +51 -0
- package/src/validate-client-metadata.ts +61 -0
- package/tsconfig.build.json +8 -0
- 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
|
+
}
|
package/tsconfig.json
ADDED