@atproto/lex-password-session 0.0.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.
Files changed (97) hide show
  1. package/README.md +413 -0
  2. package/dist/error.d.ts +8 -0
  3. package/dist/error.d.ts.map +1 -0
  4. package/dist/error.js +14 -0
  5. package/dist/error.js.map +1 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +6 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/lexicons/com/atproto/server/createAccount.d.ts +3 -0
  11. package/dist/lexicons/com/atproto/server/createAccount.d.ts.map +1 -0
  12. package/dist/lexicons/com/atproto/server/createAccount.defs.d.ts +57 -0
  13. package/dist/lexicons/com/atproto/server/createAccount.defs.d.ts.map +1 -0
  14. package/dist/lexicons/com/atproto/server/createAccount.defs.js +43 -0
  15. package/dist/lexicons/com/atproto/server/createAccount.defs.js.map +1 -0
  16. package/dist/lexicons/com/atproto/server/createAccount.js +10 -0
  17. package/dist/lexicons/com/atproto/server/createAccount.js.map +1 -0
  18. package/dist/lexicons/com/atproto/server/createSession.d.ts +3 -0
  19. package/dist/lexicons/com/atproto/server/createSession.d.ts.map +1 -0
  20. package/dist/lexicons/com/atproto/server/createSession.defs.d.ts +53 -0
  21. package/dist/lexicons/com/atproto/server/createSession.defs.d.ts.map +1 -0
  22. package/dist/lexicons/com/atproto/server/createSession.defs.js +35 -0
  23. package/dist/lexicons/com/atproto/server/createSession.defs.js.map +1 -0
  24. package/dist/lexicons/com/atproto/server/createSession.js +10 -0
  25. package/dist/lexicons/com/atproto/server/createSession.js.map +1 -0
  26. package/dist/lexicons/com/atproto/server/deleteSession.d.ts +3 -0
  27. package/dist/lexicons/com/atproto/server/deleteSession.d.ts.map +1 -0
  28. package/dist/lexicons/com/atproto/server/deleteSession.defs.d.ts +13 -0
  29. package/dist/lexicons/com/atproto/server/deleteSession.defs.d.ts.map +1 -0
  30. package/dist/lexicons/com/atproto/server/deleteSession.defs.js +19 -0
  31. package/dist/lexicons/com/atproto/server/deleteSession.defs.js.map +1 -0
  32. package/dist/lexicons/com/atproto/server/deleteSession.js +10 -0
  33. package/dist/lexicons/com/atproto/server/deleteSession.js.map +1 -0
  34. package/dist/lexicons/com/atproto/server/getSession.d.ts +3 -0
  35. package/dist/lexicons/com/atproto/server/getSession.d.ts.map +1 -0
  36. package/dist/lexicons/com/atproto/server/getSession.defs.d.ts +37 -0
  37. package/dist/lexicons/com/atproto/server/getSession.defs.d.ts.map +1 -0
  38. package/dist/lexicons/com/atproto/server/getSession.defs.js +27 -0
  39. package/dist/lexicons/com/atproto/server/getSession.defs.js.map +1 -0
  40. package/dist/lexicons/com/atproto/server/getSession.js +10 -0
  41. package/dist/lexicons/com/atproto/server/getSession.js.map +1 -0
  42. package/dist/lexicons/com/atproto/server/refreshSession.d.ts +3 -0
  43. package/dist/lexicons/com/atproto/server/refreshSession.d.ts.map +1 -0
  44. package/dist/lexicons/com/atproto/server/refreshSession.defs.d.ts +43 -0
  45. package/dist/lexicons/com/atproto/server/refreshSession.defs.d.ts.map +1 -0
  46. package/dist/lexicons/com/atproto/server/refreshSession.defs.js +30 -0
  47. package/dist/lexicons/com/atproto/server/refreshSession.defs.js.map +1 -0
  48. package/dist/lexicons/com/atproto/server/refreshSession.js +10 -0
  49. package/dist/lexicons/com/atproto/server/refreshSession.js.map +1 -0
  50. package/dist/lexicons/com/atproto/server.d.ts +6 -0
  51. package/dist/lexicons/com/atproto/server.d.ts.map +1 -0
  52. package/dist/lexicons/com/atproto/server.js +13 -0
  53. package/dist/lexicons/com/atproto/server.js.map +1 -0
  54. package/dist/lexicons/com/atproto.d.ts +2 -0
  55. package/dist/lexicons/com/atproto.d.ts.map +1 -0
  56. package/dist/lexicons/com/atproto.js +9 -0
  57. package/dist/lexicons/com/atproto.js.map +1 -0
  58. package/dist/lexicons/com.d.ts +2 -0
  59. package/dist/lexicons/com.d.ts.map +1 -0
  60. package/dist/lexicons/com.js +9 -0
  61. package/dist/lexicons/com.js.map +1 -0
  62. package/dist/lexicons/index.d.ts +2 -0
  63. package/dist/lexicons/index.d.ts.map +1 -0
  64. package/dist/lexicons/index.js +9 -0
  65. package/dist/lexicons/index.js.map +1 -0
  66. package/dist/password-session.d.ts +127 -0
  67. package/dist/password-session.d.ts.map +1 -0
  68. package/dist/password-session.js +242 -0
  69. package/dist/password-session.js.map +1 -0
  70. package/dist/util.d.ts +5 -0
  71. package/dist/util.d.ts.map +1 -0
  72. package/dist/util.js +46 -0
  73. package/dist/util.js.map +1 -0
  74. package/package.json +52 -0
  75. package/src/error.ts +14 -0
  76. package/src/index.ts +2 -0
  77. package/src/lexicons/com/atproto/server/createAccount.defs.ts +56 -0
  78. package/src/lexicons/com/atproto/server/createAccount.ts +6 -0
  79. package/src/lexicons/com/atproto/server/createSession.defs.ts +48 -0
  80. package/src/lexicons/com/atproto/server/createSession.ts +6 -0
  81. package/src/lexicons/com/atproto/server/deleteSession.defs.ts +32 -0
  82. package/src/lexicons/com/atproto/server/deleteSession.ts +6 -0
  83. package/src/lexicons/com/atproto/server/getSession.defs.ts +36 -0
  84. package/src/lexicons/com/atproto/server/getSession.ts +6 -0
  85. package/src/lexicons/com/atproto/server/refreshSession.defs.ts +43 -0
  86. package/src/lexicons/com/atproto/server/refreshSession.ts +6 -0
  87. package/src/lexicons/com/atproto/server.ts +9 -0
  88. package/src/lexicons/com/atproto.ts +5 -0
  89. package/src/lexicons/com.ts +5 -0
  90. package/src/lexicons/index.ts +5 -0
  91. package/src/password-session-utils.test.ts +177 -0
  92. package/src/password-session.test.ts +416 -0
  93. package/src/password-session.ts +404 -0
  94. package/src/util.ts +61 -0
  95. package/tsconfig.build.json +12 -0
  96. package/tsconfig.json +7 -0
  97. package/tsconfig.tests.json +9 -0
@@ -0,0 +1,43 @@
1
+ /*
2
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
3
+ */
4
+
5
+ import { l } from '@atproto/lex-schema'
6
+
7
+ const $nsid = 'com.atproto.server.refreshSession'
8
+
9
+ export { $nsid }
10
+
11
+ /** Refresh an authentication session. Requires auth using the 'refreshJwt' (not the 'accessJwt'). */
12
+ const main =
13
+ /*#__PURE__*/
14
+ l.procedure(
15
+ $nsid,
16
+ /*#__PURE__*/ l.params(),
17
+ /*#__PURE__*/ l.payload(),
18
+ /*#__PURE__*/ l.jsonPayload({
19
+ accessJwt: /*#__PURE__*/ l.string(),
20
+ refreshJwt: /*#__PURE__*/ l.string(),
21
+ handle: /*#__PURE__*/ l.string({ format: 'handle' }),
22
+ did: /*#__PURE__*/ l.string({ format: 'did' }),
23
+ didDoc: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.unknownObject()),
24
+ email: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
25
+ emailConfirmed: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.boolean()),
26
+ emailAuthFactor: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.boolean()),
27
+ active: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.boolean()),
28
+ status: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()),
29
+ }),
30
+ ['AccountTakedown', 'InvalidToken', 'ExpiredToken'],
31
+ )
32
+ export { main }
33
+
34
+ export type Params = l.InferMethodParams<typeof main>
35
+ export type Input = l.InferMethodInput<typeof main>
36
+ export type InputBody = l.InferMethodInputBody<typeof main>
37
+ export type Output = l.InferMethodOutput<typeof main>
38
+ export type OutputBody = l.InferMethodOutputBody<typeof main>
39
+
40
+ export const $lxm = /*#__PURE__*/ main.nsid,
41
+ $params = /*#__PURE__*/ main.parameters,
42
+ $input = /*#__PURE__*/ main.input,
43
+ $output = /*#__PURE__*/ main.output
@@ -0,0 +1,6 @@
1
+ /*
2
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
3
+ */
4
+
5
+ export * from './refreshSession.defs.js'
6
+ export * as $defs from './refreshSession.defs.js'
@@ -0,0 +1,9 @@
1
+ /*
2
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
3
+ */
4
+
5
+ export * as createAccount from './server/createAccount.js'
6
+ export * as createSession from './server/createSession.js'
7
+ export * as deleteSession from './server/deleteSession.js'
8
+ export * as getSession from './server/getSession.js'
9
+ export * as refreshSession from './server/refreshSession.js'
@@ -0,0 +1,5 @@
1
+ /*
2
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
3
+ */
4
+
5
+ export * as server from './atproto/server.js'
@@ -0,0 +1,5 @@
1
+ /*
2
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
3
+ */
4
+
5
+ export * as atproto from './com/atproto.js'
@@ -0,0 +1,5 @@
1
+ /*
2
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
3
+ */
4
+
5
+ export * as com from './com.js'
@@ -0,0 +1,177 @@
1
+ /* eslint-disable @typescript-eslint/no-namespace */
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+ import { DidString, HandleString } from '@atproto/lex-schema'
5
+ import { LexServerAuthError } from '@atproto/lex-server'
6
+
7
+ const randomString = () =>
8
+ Math.random().toString(36).substring(2, 10) +
9
+ Math.random().toString(36).substring(2, 10)
10
+
11
+ export class Session {
12
+ active = true
13
+ accessJwt = randomString()
14
+ refreshJwt = randomString()
15
+ constructor(readonly identifier: string) {}
16
+ get did(): DidString {
17
+ return `did:example:${this.identifier}`
18
+ }
19
+ get handle(): HandleString {
20
+ return `${this.identifier}.example`
21
+ }
22
+ get email(): string {
23
+ return `${this.identifier}@example.com`
24
+ }
25
+ rotate() {
26
+ this.accessJwt = randomString()
27
+ this.refreshJwt = randomString()
28
+ return this
29
+ }
30
+ destroy() {
31
+ this.active = false
32
+ }
33
+ }
34
+
35
+ describe('Session', () => {
36
+ it('generates DID and handle from identifier', async () => {
37
+ const session = new Session('alice')
38
+ expect(session.did).toBe('did:example:alice')
39
+ expect(session.handle).toBe('alice.example')
40
+ })
41
+
42
+ it('rotates tokens', async () => {
43
+ const session = new Session('alice')
44
+ const oldAccess = session.accessJwt
45
+ const oldRefresh = session.refreshJwt
46
+ session.rotate()
47
+ expect(session.accessJwt).not.toBe(oldAccess)
48
+ expect(session.refreshJwt).not.toBe(oldRefresh)
49
+ })
50
+
51
+ it('destroys session', async () => {
52
+ const session = new Session('alice')
53
+ expect(session.active).toBe(true)
54
+ session.destroy()
55
+ expect(session.active).toBe(false)
56
+ })
57
+ })
58
+
59
+ export class AuthVerifier {
60
+ sessions: Session[] = []
61
+
62
+ async create(credentials: {
63
+ identifier: string
64
+ password: string
65
+ authFactorToken?: string
66
+ }) {
67
+ if (!credentials.identifier || credentials.password !== 'password123') {
68
+ throw new LexServerAuthError(
69
+ 'AuthenticationRequired',
70
+ 'Invalid identifier',
71
+ )
72
+ }
73
+ if (credentials.authFactorToken !== '2fa-token') {
74
+ throw new LexServerAuthError(
75
+ 'AuthFactorTokenRequired',
76
+ '2FA token is required',
77
+ )
78
+ }
79
+ const session = new Session(credentials.identifier)
80
+ this.sessions.push(session)
81
+ return session
82
+ }
83
+
84
+ async findBy(predicate: (s: Session) => boolean) {
85
+ return this.sessions.find((s) => s.active && predicate(s))
86
+ }
87
+
88
+ accessStrategy = async ({ request }: { request: Request }) => {
89
+ const auth = request.headers.get('authorization')
90
+ const token = auth?.startsWith('Bearer ') && auth.slice(7)
91
+ const session = await this.findBy((s) => s.accessJwt === token)
92
+ if (!session) {
93
+ throw new LexServerAuthError('AuthenticationRequired', 'Invalid token', {
94
+ Bearer: { realm: 'access token' },
95
+ })
96
+ }
97
+ return { session }
98
+ }
99
+
100
+ refreshStrategy = async ({ request }: { request: Request }) => {
101
+ const auth = request.headers.get('authorization')
102
+ const token = auth?.startsWith('Bearer ') && auth.slice(7)
103
+ const session = await this.findBy((s) => s.refreshJwt === token)
104
+ if (!session) {
105
+ throw new LexServerAuthError('ExpiredToken', 'Invalid token', {
106
+ Bearer: { realm: 'refresh token' },
107
+ })
108
+ }
109
+ return { session }
110
+ }
111
+ }
112
+
113
+ describe('AuthVerifier', () => {
114
+ it('creates session with valid credentials', async () => {
115
+ const verifier = new AuthVerifier()
116
+ const session = await verifier.create({
117
+ identifier: 'alice',
118
+ password: 'password123',
119
+ authFactorToken: '2fa-token',
120
+ })
121
+ expect(session.identifier).toBe('alice')
122
+ })
123
+
124
+ it('rejects invalid credentials', async () => {
125
+ const verifier = new AuthVerifier()
126
+ await expect(
127
+ verifier.create({
128
+ identifier: 'alice',
129
+ password: 'wrong-password',
130
+ }),
131
+ ).rejects.toMatchObject({
132
+ error: 'AuthenticationRequired',
133
+ })
134
+ })
135
+
136
+ it('rejects missing 2fa token', async () => {
137
+ const verifier = new AuthVerifier()
138
+ await expect(
139
+ verifier.create({
140
+ identifier: 'alice',
141
+ password: 'password123',
142
+ }),
143
+ ).rejects.toMatchObject({
144
+ error: 'AuthFactorTokenRequired',
145
+ })
146
+ })
147
+
148
+ it('finds session by access token', async () => {
149
+ const verifier = new AuthVerifier()
150
+ const session = await verifier.create({
151
+ identifier: 'alice',
152
+ password: 'password123',
153
+ authFactorToken: '2fa-token',
154
+ })
155
+ const found = await verifier.accessStrategy({
156
+ request: new Request('http://example.com', {
157
+ headers: { authorization: `Bearer ${session.accessJwt}` },
158
+ }),
159
+ })
160
+ expect(found.session).toBe(session)
161
+ })
162
+
163
+ it('finds session by refresh token', async () => {
164
+ const verifier = new AuthVerifier()
165
+ const session = await verifier.create({
166
+ identifier: 'alice',
167
+ password: 'password123',
168
+ authFactorToken: '2fa-token',
169
+ })
170
+ const found = await verifier.refreshStrategy({
171
+ request: new Request('http://example.com', {
172
+ headers: { authorization: `Bearer ${session.refreshJwt}` },
173
+ }),
174
+ })
175
+ expect(found.session).toBe(session)
176
+ })
177
+ })
@@ -0,0 +1,416 @@
1
+ /* eslint-disable @typescript-eslint/no-namespace */
2
+
3
+ import { afterAll, assert, beforeAll, describe, expect, it, vi } from 'vitest'
4
+ import { Client, LexRpcResponseError } from '@atproto/lex-client'
5
+ import { l } from '@atproto/lex-schema'
6
+ import { LexRouter, LexServerAuthError } from '@atproto/lex-server'
7
+ import { Server, serve } from '@atproto/lex-server/nodejs'
8
+ import { LexAuthFactorError } from './error.js'
9
+ import { com } from './lexicons/index.js'
10
+ import { AuthVerifier } from './password-session-utils.test.js'
11
+ import {
12
+ PasswordSession,
13
+ PasswordSessionOptions,
14
+ SessionData,
15
+ } from './password-session.js'
16
+
17
+ const defaultOptions: Partial<PasswordSessionOptions> = {
18
+ onUpdateFailure: async (session, cause) => {
19
+ throw new Error('Should not fail to refresh session', { cause })
20
+ },
21
+ onDeleteFailure: async (session, cause) => {
22
+ throw new Error('Should not fail to delete session', { cause })
23
+ },
24
+ }
25
+
26
+ // Example app lexicon
27
+ namespace app {
28
+ export namespace example {
29
+ export namespace customMethod {
30
+ export const main = l.procedure(
31
+ 'app.example.customMethod',
32
+ l.params(),
33
+ l.jsonPayload({ message: l.string() }),
34
+ l.jsonPayload({
35
+ message: l.string(),
36
+ did: l.string({ format: 'did' }),
37
+ }),
38
+ )
39
+ }
40
+
41
+ export namespace expiredToken {
42
+ export const main = l.query(
43
+ 'app.example.expiredToken',
44
+ l.params(),
45
+ l.payload(),
46
+ )
47
+ }
48
+ }
49
+ }
50
+
51
+ describe(PasswordSession, () => {
52
+ let entrywayServer: Server
53
+ let entrywayOrigin: string
54
+
55
+ let pdsServer: Server
56
+ let pdsOrigin: string
57
+
58
+ beforeAll(async () => {
59
+ const authVerifier = new AuthVerifier()
60
+
61
+ const entrywayRouter = new LexRouter()
62
+ .add(com.atproto.server.createSession, async ({ input }) => {
63
+ const session = await authVerifier.create(input.body)
64
+
65
+ const body: com.atproto.server.createSession.OutputBody = {
66
+ accessJwt: session.accessJwt,
67
+ refreshJwt: session.refreshJwt,
68
+
69
+ did: session.did,
70
+ didDoc: {
71
+ '@context': 'https://w3.org/ns/did/v1',
72
+ id: session.did,
73
+ service: [
74
+ {
75
+ id: `${session.did}#atproto_pds`,
76
+ type: 'AtprotoPersonalDataServer',
77
+ serviceEndpoint: pdsUrl,
78
+ },
79
+ ],
80
+ },
81
+ handle: session.handle,
82
+ }
83
+
84
+ return { body }
85
+ })
86
+ .add(com.atproto.server.getSession, {
87
+ auth: authVerifier.accessStrategy,
88
+ handler: async ({ credentials: { session } }) => {
89
+ const body: com.atproto.server.getSession.OutputBody = {
90
+ did: session.did,
91
+ didDoc: {
92
+ '@context': 'https://w3.org/ns/did/v1',
93
+ id: session.did,
94
+ service: [
95
+ {
96
+ id: `${session.did}#atproto_pds`,
97
+ type: 'AtprotoPersonalDataServer',
98
+ serviceEndpoint: pdsOrigin,
99
+ },
100
+ ],
101
+ },
102
+ handle: session.handle,
103
+ email: session.email,
104
+ emailConfirmed: true,
105
+ emailAuthFactor: false,
106
+ active: true,
107
+ status: 'active',
108
+ }
109
+
110
+ return { body }
111
+ },
112
+ })
113
+ .add(com.atproto.server.refreshSession, {
114
+ auth: authVerifier.refreshStrategy,
115
+ handler: async ({ credentials: { session } }) => {
116
+ await session.rotate()
117
+
118
+ // Note, we omit email and didDoc here to test that they are properly
119
+ // fetched via getSession in the agent
120
+ const body: com.atproto.server.refreshSession.OutputBody = {
121
+ accessJwt: session.accessJwt,
122
+ refreshJwt: session.refreshJwt,
123
+
124
+ did: session.did,
125
+ didDoc: undefined,
126
+ handle: session.handle,
127
+
128
+ email: undefined,
129
+ emailConfirmed: undefined,
130
+ }
131
+
132
+ return { body }
133
+ },
134
+ })
135
+ .add(com.atproto.server.deleteSession, {
136
+ auth: authVerifier.refreshStrategy,
137
+ handler: async ({ credentials: { session } }) => {
138
+ await session.destroy()
139
+ return {}
140
+ },
141
+ })
142
+
143
+ entrywayServer = await serve(entrywayRouter)
144
+ const { port } = entrywayServer.address() as { port: number }
145
+ entrywayOrigin = `http://localhost:${port}`
146
+
147
+ const pdsRouter = new LexRouter()
148
+ .add(app.example.customMethod, {
149
+ auth: authVerifier.accessStrategy,
150
+ handler: async ({ input, credentials: { session } }) => {
151
+ return { body: { message: input.body.message, did: session.did } }
152
+ },
153
+ })
154
+ .add(app.example.expiredToken, async () => {
155
+ throw new LexServerAuthError('ExpiredToken', 'Token expired')
156
+ })
157
+
158
+ pdsServer = await serve(pdsRouter)
159
+ const { port: pdsPort } = pdsServer.address() as { port: number }
160
+ pdsOrigin = `http://localhost:${pdsPort}`
161
+ const pdsUrl = pdsOrigin
162
+ })
163
+
164
+ afterAll(async () => {
165
+ entrywayServer.close()
166
+ pdsServer.close()
167
+ })
168
+
169
+ it('fails with invalid credentials', async () => {
170
+ const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
171
+ const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
172
+
173
+ await expect(
174
+ PasswordSession.create({
175
+ ...defaultOptions,
176
+ service: entrywayOrigin,
177
+ identifier: 'alice',
178
+ password: 'wrong-password',
179
+ onDeleted,
180
+ onUpdated,
181
+ }),
182
+ ).rejects.toMatchObject({
183
+ success: false,
184
+ status: 401,
185
+ error: 'AuthenticationRequired',
186
+ })
187
+
188
+ expect(onDeleted).not.toHaveBeenCalled()
189
+ expect(onUpdated).not.toHaveBeenCalled()
190
+ })
191
+
192
+ it('requires 2fa', async () => {
193
+ const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
194
+ const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
195
+
196
+ const result = await PasswordSession.create({
197
+ ...defaultOptions,
198
+ service: entrywayOrigin,
199
+ identifier: 'alice',
200
+ password: 'password123',
201
+ onDeleted,
202
+ onUpdated,
203
+ }).then(
204
+ () => {
205
+ throw new Error('Expected to fail')
206
+ },
207
+ (err: unknown) => err,
208
+ )
209
+
210
+ assert(result instanceof LexAuthFactorError)
211
+ expect(result.error).toBe('AuthFactorTokenRequired')
212
+ expect(onDeleted).not.toHaveBeenCalled()
213
+ expect(onUpdated).not.toHaveBeenCalled()
214
+ })
215
+
216
+ it('logs in', async () => {
217
+ const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
218
+ const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
219
+
220
+ const session = await PasswordSession.create({
221
+ ...defaultOptions,
222
+ service: entrywayOrigin,
223
+ identifier: 'alice',
224
+ password: 'password123',
225
+ authFactorToken: '2fa-token',
226
+ onDeleted,
227
+ onUpdated,
228
+ })
229
+
230
+ expect(onUpdated).toHaveBeenCalledTimes(1)
231
+
232
+ const client = new Client(session)
233
+
234
+ await expect(
235
+ client.call(app.example.customMethod, { message: 'hello' }),
236
+ ).resolves.toMatchObject({
237
+ message: 'hello',
238
+ did: 'did:example:alice',
239
+ })
240
+
241
+ await expect(
242
+ client.call(app.example.customMethod, { message: 'world' }),
243
+ ).resolves.toMatchObject({
244
+ message: 'world',
245
+ did: 'did:example:alice',
246
+ })
247
+
248
+ expect(onDeleted).not.toHaveBeenCalled()
249
+
250
+ await session.logout()
251
+
252
+ expect(onDeleted).toHaveBeenCalled()
253
+
254
+ await expect(
255
+ client.call(app.example.customMethod, { message: 'hello' }),
256
+ ).rejects.toThrow('Logged out')
257
+ })
258
+
259
+ it('fails to perform unauthenticated call', async () => {
260
+ const client = new Client(pdsOrigin)
261
+ const result = await client.xrpcSafe(app.example.customMethod, {
262
+ body: { message: 'hello' },
263
+ })
264
+
265
+ assert(result.success === false)
266
+ assert(result instanceof LexRpcResponseError)
267
+ expect(result).toMatchObject({
268
+ success: false,
269
+ status: 401,
270
+ error: 'AuthenticationRequired',
271
+ })
272
+ expect(result.headers.get('www-authenticate')).toBe(
273
+ 'Bearer realm="access token"',
274
+ )
275
+ })
276
+
277
+ it('refreshes expired token', async () => {
278
+ const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
279
+ const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
280
+
281
+ const session = await PasswordSession.create({
282
+ ...defaultOptions,
283
+ service: entrywayOrigin,
284
+ identifier: 'bob',
285
+ password: 'password123',
286
+ authFactorToken: '2fa-token',
287
+ onUpdated,
288
+ onDeleted,
289
+ })
290
+
291
+ const client = new Client(session)
292
+
293
+ await expect(
294
+ client.call(app.example.customMethod, { message: 'before' }),
295
+ ).resolves.toMatchObject({
296
+ message: 'before',
297
+ did: 'did:example:bob',
298
+ })
299
+
300
+ expect(onUpdated).toHaveBeenCalledTimes(1)
301
+
302
+ await expect(client.call(app.example.expiredToken)).rejects.toThrow(
303
+ 'Token expired',
304
+ )
305
+
306
+ expect(onUpdated).toHaveBeenCalledTimes(2)
307
+ expect(onUpdated).toHaveBeenLastCalledWith(
308
+ expect.objectContaining({
309
+ service: entrywayOrigin,
310
+
311
+ accessJwt: expect.any(String),
312
+ refreshJwt: expect.any(String),
313
+
314
+ email: expect.stringContaining('@'),
315
+ emailConfirmed: true,
316
+ emailAuthFactor: false,
317
+ handle: 'bob.example',
318
+ did: 'did:example:bob',
319
+ didDoc: expect.objectContaining({ id: 'did:example:bob' }),
320
+ }),
321
+ )
322
+
323
+ await expect(
324
+ client.call(app.example.customMethod, { message: 'after' }),
325
+ ).resolves.toMatchObject({
326
+ message: 'after',
327
+ did: 'did:example:bob',
328
+ })
329
+ })
330
+
331
+ it('restores session from storage', async () => {
332
+ const onDeleted: PasswordSessionOptions['onDeleted'] = vi.fn()
333
+ const onUpdated: PasswordSessionOptions['onUpdated'] = vi.fn()
334
+
335
+ const initialAgent = await PasswordSession.create({
336
+ ...defaultOptions,
337
+ service: entrywayOrigin,
338
+ identifier: 'carla',
339
+ password: 'password123',
340
+ authFactorToken: '2fa-token',
341
+ onUpdated,
342
+ onDeleted,
343
+ })
344
+
345
+ expect(initialAgent.did).toEqual('did:example:carla')
346
+ expect(onDeleted).toHaveBeenCalledTimes(0)
347
+ expect(onUpdated).toHaveBeenCalledTimes(1)
348
+ expect(onUpdated).toHaveBeenCalledWith(
349
+ expect.objectContaining({
350
+ accessJwt: expect.any(String),
351
+ refreshJwt: expect.any(String),
352
+ }),
353
+ )
354
+
355
+ const sessionData = initialAgent.session
356
+
357
+ const resumedAgent = await PasswordSession.resume(sessionData, {
358
+ ...defaultOptions,
359
+ onUpdated,
360
+ onDeleted,
361
+ })
362
+
363
+ expect(resumedAgent.did).toEqual('did:example:carla')
364
+ expect(onDeleted).toHaveBeenCalledTimes(0)
365
+ expect(onUpdated).toHaveBeenCalledTimes(2)
366
+
367
+ // The initial session was refreshed. The data it contains is now invalid.
368
+ await expect(initialAgent.refresh()).rejects.toMatchObject({
369
+ success: false,
370
+ error: 'ExpiredToken',
371
+ status: 401,
372
+ })
373
+
374
+ expect(onDeleted).toHaveBeenCalledTimes(1)
375
+
376
+ const client = new Client(resumedAgent)
377
+ await expect(
378
+ client.call(app.example.customMethod, { message: 'resume' }),
379
+ ).resolves.toMatchObject({
380
+ message: 'resume',
381
+ did: 'did:example:carla',
382
+ })
383
+
384
+ expect(onDeleted).toHaveBeenCalledTimes(1)
385
+ expect(onUpdated).toHaveBeenCalledTimes(2)
386
+
387
+ await resumedAgent.logout()
388
+
389
+ expect(onDeleted).toHaveBeenCalledTimes(2)
390
+ expect(onUpdated).toHaveBeenCalledTimes(2)
391
+ })
392
+
393
+ it('silently ignores expected logout errors', async () => {
394
+ let sessionData: SessionData | null = null
395
+
396
+ const session = await PasswordSession.create({
397
+ ...defaultOptions,
398
+ service: entrywayOrigin,
399
+ identifier: 'dave',
400
+ password: 'password123',
401
+ authFactorToken: '2fa-token',
402
+ onUpdated: (data) => {
403
+ sessionData = structuredClone(data)
404
+ },
405
+ onDeleted: () => {},
406
+ })
407
+
408
+ assert(sessionData)
409
+
410
+ await session.logout()
411
+ await session.logout()
412
+
413
+ await PasswordSession.delete(sessionData)
414
+ await PasswordSession.delete(sessionData)
415
+ })
416
+ })