@atcute/password-session 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/LICENSE +14 -0
- package/README.md +111 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/password-session.d.ts +136 -0
- package/dist/password-session.d.ts.map +1 -0
- package/dist/password-session.js +371 -0
- package/dist/password-session.js.map +1 -0
- package/lib/index.ts +1 -0
- package/lib/password-session.ts +557 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
BSD Zero Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Mary
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
9
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
10
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
11
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
12
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
13
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
14
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @atcute/password-session
|
|
2
|
+
|
|
3
|
+
password-based session management for AT Protocol services. manages access/refresh token lifecycle,
|
|
4
|
+
automatic refresh on 401, and session persistence via callbacks.
|
|
5
|
+
|
|
6
|
+
for browser-based applications, prefer OAuth-based authentication. when using password auth, use app
|
|
7
|
+
passwords rather than main account credentials.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install @atcute/password-session @atcute/client @atcute/bluesky
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## usage
|
|
14
|
+
|
|
15
|
+
### login
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { Client, ok } from '@atcute/client';
|
|
19
|
+
import { PasswordSession } from '@atcute/password-session';
|
|
20
|
+
|
|
21
|
+
import type {} from '@atcute/bluesky';
|
|
22
|
+
|
|
23
|
+
const session = await PasswordSession.login(
|
|
24
|
+
{ service: 'https://bsky.social', identifier: 'you.bsky.social', password: 'your-app-password' },
|
|
25
|
+
{
|
|
26
|
+
onUpdate(data) {
|
|
27
|
+
// called on login and token refresh — persist the session
|
|
28
|
+
localStorage.setItem('session', JSON.stringify(data));
|
|
29
|
+
},
|
|
30
|
+
onDelete(data) {
|
|
31
|
+
// called on logout or session invalidation — clean up
|
|
32
|
+
localStorage.removeItem('session');
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const rpc = new Client({ handler: session });
|
|
38
|
+
|
|
39
|
+
const data = await ok(rpc.get('com.atproto.server.getSession'));
|
|
40
|
+
console.log(data.did);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### URL shorthand
|
|
44
|
+
|
|
45
|
+
for bots and scripts, use URL shorthand with `await using` for automatic logout:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
await using session = await PasswordSession.login('https://handle:app-pass@bsky.social');
|
|
49
|
+
const rpc = new Client({ handler: session });
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### resuming sessions
|
|
53
|
+
|
|
54
|
+
resume a persisted session without re-entering credentials:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
const saved = localStorage.getItem('session');
|
|
58
|
+
if (saved) {
|
|
59
|
+
const session = await PasswordSession.resume(JSON.parse(saved), {
|
|
60
|
+
onUpdate(data) {
|
|
61
|
+
localStorage.setItem('session', JSON.stringify(data));
|
|
62
|
+
},
|
|
63
|
+
onDelete(data) {
|
|
64
|
+
localStorage.removeItem('session');
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
const rpc = new Client({ handler: session });
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### cached session with credential fallback
|
|
72
|
+
|
|
73
|
+
for bots with both stored credentials and cached sessions, `login()` can try the cached session
|
|
74
|
+
first and fall back to fresh authentication:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const session = await PasswordSession.login('https://handle:app-pass@bsky.social', {
|
|
78
|
+
session: loadFromDisk(),
|
|
79
|
+
onUpdate(data) {
|
|
80
|
+
saveToDisk(data);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### lazy construction
|
|
86
|
+
|
|
87
|
+
if you don't need upfront validation, construct directly — tokens refresh lazily on 401:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const session = new PasswordSession(savedData, { onUpdate, onDelete });
|
|
91
|
+
const rpc = new Client({ handler: session });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### cleanup
|
|
95
|
+
|
|
96
|
+
delete an orphaned session server-side without resuming:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
await PasswordSession.delete(savedData);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## callbacks
|
|
103
|
+
|
|
104
|
+
| callback | when | session state |
|
|
105
|
+
| ----------------- | ------------------------------------------- | ------------------ |
|
|
106
|
+
| `onUpdate` | login succeeds, tokens refresh successfully | active (updated) |
|
|
107
|
+
| `onUpdateFailure` | token refresh fails transiently (network) | active (preserved) |
|
|
108
|
+
| `onDelete` | logout succeeds, session invalidated | destroyed |
|
|
109
|
+
| `onDeleteFailure` | logout fails transiently (network) | active (preserved) |
|
|
110
|
+
|
|
111
|
+
all callbacks receive `this: PasswordSession` context and must not throw.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { type FetchHandlerObject } from '@atcute/client';
|
|
2
|
+
import type { Did } from '@atcute/lexicons';
|
|
3
|
+
/** persistable session data */
|
|
4
|
+
export interface PasswordSessionData {
|
|
5
|
+
/** authentication service URL */
|
|
6
|
+
service: string;
|
|
7
|
+
accessJwt: string;
|
|
8
|
+
refreshJwt: string;
|
|
9
|
+
handle: string;
|
|
10
|
+
did: Did;
|
|
11
|
+
/** PDS endpoint derived from DID document */
|
|
12
|
+
pdsUri?: string;
|
|
13
|
+
email?: string;
|
|
14
|
+
emailConfirmed?: boolean;
|
|
15
|
+
emailAuthFactor?: boolean;
|
|
16
|
+
active: boolean;
|
|
17
|
+
inactiveStatus?: string;
|
|
18
|
+
}
|
|
19
|
+
export interface PasswordSessionOptions {
|
|
20
|
+
/** custom fetch implementation */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
/**
|
|
23
|
+
* called when session is successfully created or refreshed with new
|
|
24
|
+
* credentials. use this to persist the updated session.
|
|
25
|
+
* receives `this: PasswordSession` context.
|
|
26
|
+
* @note must not throw
|
|
27
|
+
*/
|
|
28
|
+
onUpdate?: (this: PasswordSession, data: PasswordSessionData) => void | Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* called when a session refresh fails due to a transient error (network,
|
|
31
|
+
* server down). the session is preserved — consider retry logic.
|
|
32
|
+
* @note must not throw
|
|
33
|
+
*/
|
|
34
|
+
onUpdateFailure?: (this: PasswordSession, data: PasswordSessionData, error: unknown) => void | Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* called when the session is terminated — either explicit logout or
|
|
37
|
+
* server-side invalidation (expired/invalid refresh token).
|
|
38
|
+
* use this to clean up persisted session data.
|
|
39
|
+
* @note must not throw
|
|
40
|
+
*/
|
|
41
|
+
onDelete?: (this: PasswordSession, data: PasswordSessionData) => void | Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* called when logout network request fails due to a transient error.
|
|
44
|
+
* the session stays active locally so you can retry.
|
|
45
|
+
* @note must not throw
|
|
46
|
+
*/
|
|
47
|
+
onDeleteFailure?: (this: PasswordSession, data: PasswordSessionData, error: unknown) => void | Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
/** credentials for login */
|
|
50
|
+
export interface PasswordSessionLoginCredentials {
|
|
51
|
+
service: string;
|
|
52
|
+
identifier: string;
|
|
53
|
+
password: string;
|
|
54
|
+
/** two-factor authentication code */
|
|
55
|
+
code?: string;
|
|
56
|
+
/** allow signing in even if the account has been taken down */
|
|
57
|
+
allowTakendown?: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** options for login — second parameter, behavior config */
|
|
60
|
+
export interface PasswordSessionLoginOptions extends PasswordSessionOptions {
|
|
61
|
+
/** cached session to try resuming before falling back to fresh login */
|
|
62
|
+
session?: PasswordSessionData;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* password-based authentication session for AT Protocol services.
|
|
66
|
+
*
|
|
67
|
+
* manages access/refresh token lifecycle, automatic refresh on 401, and
|
|
68
|
+
* session persistence via callbacks. instances are always in an authenticated
|
|
69
|
+
* state — use the static factories for validated construction.
|
|
70
|
+
*
|
|
71
|
+
* for browser-based applications, prefer OAuth-based authentication instead.
|
|
72
|
+
* when using password auth, use app passwords rather than main account credentials.
|
|
73
|
+
*/
|
|
74
|
+
export declare class PasswordSession implements FetchHandlerObject, AsyncDisposable {
|
|
75
|
+
#private;
|
|
76
|
+
/**
|
|
77
|
+
* construct with existing session data. tokens refresh lazily on 401.
|
|
78
|
+
* use static `login()` or `resume()` for validated sessions.
|
|
79
|
+
* @param session existing session data
|
|
80
|
+
* @param options session options
|
|
81
|
+
*/
|
|
82
|
+
constructor(session: PasswordSessionData, options?: PasswordSessionOptions);
|
|
83
|
+
/**
|
|
84
|
+
* account DID
|
|
85
|
+
* @throws if the session has been destroyed
|
|
86
|
+
*/
|
|
87
|
+
get did(): Did;
|
|
88
|
+
/** whether this session has been destroyed (logged out) */
|
|
89
|
+
get destroyed(): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* current session data — serialize this for persistence
|
|
92
|
+
* @throws if the session has been destroyed
|
|
93
|
+
*/
|
|
94
|
+
get session(): PasswordSessionData;
|
|
95
|
+
/** URL to dispatch API requests to (PDS from DID doc, or service URL) */
|
|
96
|
+
get dispatchUrl(): string;
|
|
97
|
+
/**
|
|
98
|
+
* authenticate with credentials. optionally tries resuming a cached
|
|
99
|
+
* session first, falling back to fresh createSession on failure.
|
|
100
|
+
* @param credentials login credentials or URL shorthand (`https://handle:pass@service`)
|
|
101
|
+
* @param options login options
|
|
102
|
+
* @returns authenticated session
|
|
103
|
+
*/
|
|
104
|
+
static login(credentials: PasswordSessionLoginCredentials | string | URL, options?: PasswordSessionLoginOptions): Promise<PasswordSession>;
|
|
105
|
+
/**
|
|
106
|
+
* resume from persisted session data. if the access token is still valid,
|
|
107
|
+
* returns immediately and refreshes metadata in the background.
|
|
108
|
+
* if expired, refreshes synchronously. throws only if the session is
|
|
109
|
+
* definitively invalid.
|
|
110
|
+
* @param session persisted session data
|
|
111
|
+
* @param options session options
|
|
112
|
+
* @returns resumed session
|
|
113
|
+
*/
|
|
114
|
+
static resume(session: PasswordSessionData, options?: PasswordSessionOptions): Promise<PasswordSession>;
|
|
115
|
+
/**
|
|
116
|
+
* delete a session server-side without resuming it.
|
|
117
|
+
* useful for cleanup of orphaned sessions.
|
|
118
|
+
* @param session session data to delete
|
|
119
|
+
* @param options session options
|
|
120
|
+
*/
|
|
121
|
+
static delete(session: PasswordSessionData, options?: PasswordSessionOptions): Promise<void>;
|
|
122
|
+
/** refresh the session tokens */
|
|
123
|
+
refresh(): Promise<void>;
|
|
124
|
+
/**
|
|
125
|
+
* sign out — invalidates session server-side.
|
|
126
|
+
* on success, the session is destroyed and `onDelete` is called.
|
|
127
|
+
* on transient failure (network), `onDeleteFailure` is called and
|
|
128
|
+
* the session stays active for retry.
|
|
129
|
+
* @throws on transient failure when the session couldn't be deleted
|
|
130
|
+
*/
|
|
131
|
+
logout(): Promise<void>;
|
|
132
|
+
/** AsyncDisposable — calls `logout()` */
|
|
133
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
134
|
+
handle(pathname: string, init: RequestInit): Promise<Response>;
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=password-session.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"password-session.d.ts","sourceRoot":"","sources":["../lib/password-session.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { Client, ClientResponseError, isXRPCErrorPayload, ok, simpleFetchHandler, } from '@atcute/client';
|
|
2
|
+
import { getPdsEndpoint } from '@atcute/identity';
|
|
3
|
+
// #endregion
|
|
4
|
+
// #region class
|
|
5
|
+
/**
|
|
6
|
+
* password-based authentication session for AT Protocol services.
|
|
7
|
+
*
|
|
8
|
+
* manages access/refresh token lifecycle, automatic refresh on 401, and
|
|
9
|
+
* session persistence via callbacks. instances are always in an authenticated
|
|
10
|
+
* state — use the static factories for validated construction.
|
|
11
|
+
*
|
|
12
|
+
* for browser-based applications, prefer OAuth-based authentication instead.
|
|
13
|
+
* when using password auth, use app passwords rather than main account credentials.
|
|
14
|
+
*/
|
|
15
|
+
export class PasswordSession {
|
|
16
|
+
#sessionData;
|
|
17
|
+
#sessionPromise;
|
|
18
|
+
#server;
|
|
19
|
+
#fetch;
|
|
20
|
+
#onUpdate;
|
|
21
|
+
#onUpdateFailure;
|
|
22
|
+
#onDelete;
|
|
23
|
+
#onDeleteFailure;
|
|
24
|
+
/**
|
|
25
|
+
* construct with existing session data. tokens refresh lazily on 401.
|
|
26
|
+
* use static `login()` or `resume()` for validated sessions.
|
|
27
|
+
* @param session existing session data
|
|
28
|
+
* @param options session options
|
|
29
|
+
*/
|
|
30
|
+
constructor(session, options = {}) {
|
|
31
|
+
this.#sessionData = session;
|
|
32
|
+
this.#sessionPromise = Promise.resolve(session);
|
|
33
|
+
this.#fetch = options.fetch ?? fetch;
|
|
34
|
+
this.#server = new Client({
|
|
35
|
+
handler: simpleFetchHandler({ service: session.service, fetch: this.#fetch }),
|
|
36
|
+
});
|
|
37
|
+
this.#onUpdate = options.onUpdate;
|
|
38
|
+
this.#onUpdateFailure = options.onUpdateFailure;
|
|
39
|
+
this.#onDelete = options.onDelete;
|
|
40
|
+
this.#onDeleteFailure = options.onDeleteFailure;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* account DID
|
|
44
|
+
* @throws if the session has been destroyed
|
|
45
|
+
*/
|
|
46
|
+
get did() {
|
|
47
|
+
return this.session.did;
|
|
48
|
+
}
|
|
49
|
+
/** whether this session has been destroyed (logged out) */
|
|
50
|
+
get destroyed() {
|
|
51
|
+
return this.#sessionData === null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* current session data — serialize this for persistence
|
|
55
|
+
* @throws if the session has been destroyed
|
|
56
|
+
*/
|
|
57
|
+
get session() {
|
|
58
|
+
if (this.#sessionData) {
|
|
59
|
+
return this.#sessionData;
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`session has been destroyed`);
|
|
62
|
+
}
|
|
63
|
+
/** URL to dispatch API requests to (PDS from DID doc, or service URL) */
|
|
64
|
+
get dispatchUrl() {
|
|
65
|
+
return this.session.pdsUri ?? this.session.service;
|
|
66
|
+
}
|
|
67
|
+
// --- static factories ---
|
|
68
|
+
/**
|
|
69
|
+
* authenticate with credentials. optionally tries resuming a cached
|
|
70
|
+
* session first, falling back to fresh createSession on failure.
|
|
71
|
+
* @param credentials login credentials or URL shorthand (`https://handle:pass@service`)
|
|
72
|
+
* @param options login options
|
|
73
|
+
* @returns authenticated session
|
|
74
|
+
*/
|
|
75
|
+
static async login(credentials, options = {}) {
|
|
76
|
+
const creds = typeof credentials === 'string' || credentials instanceof URL
|
|
77
|
+
? parseLoginUrl(credentials)
|
|
78
|
+
: credentials;
|
|
79
|
+
// try cached session first if provided
|
|
80
|
+
if (options.session) {
|
|
81
|
+
try {
|
|
82
|
+
return await PasswordSession.resume(options.session, options);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// fall through to fresh login
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const _fetch = options.fetch ?? fetch;
|
|
89
|
+
const server = new Client({
|
|
90
|
+
handler: simpleFetchHandler({ service: creds.service, fetch: _fetch }),
|
|
91
|
+
});
|
|
92
|
+
const data = await ok(server.post('com.atproto.server.createSession', {
|
|
93
|
+
input: {
|
|
94
|
+
identifier: creds.identifier,
|
|
95
|
+
password: creds.password,
|
|
96
|
+
authFactorToken: creds.code,
|
|
97
|
+
allowTakendown: creds.allowTakendown,
|
|
98
|
+
},
|
|
99
|
+
}));
|
|
100
|
+
const sessionData = buildSessionData(creds.service, data);
|
|
101
|
+
const session = new PasswordSession(sessionData, options);
|
|
102
|
+
await options.onUpdate?.call(session, sessionData);
|
|
103
|
+
return session;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* resume from persisted session data. if the access token is still valid,
|
|
107
|
+
* returns immediately and refreshes metadata in the background.
|
|
108
|
+
* if expired, refreshes synchronously. throws only if the session is
|
|
109
|
+
* definitively invalid.
|
|
110
|
+
* @param session persisted session data
|
|
111
|
+
* @param options session options
|
|
112
|
+
* @returns resumed session
|
|
113
|
+
*/
|
|
114
|
+
static async resume(session, options = {}) {
|
|
115
|
+
const instance = new PasswordSession(session, options);
|
|
116
|
+
const now = Date.now() / 1_000 + 60 * 5;
|
|
117
|
+
const accessToken = decodeJwt(session.accessJwt);
|
|
118
|
+
if (now >= accessToken.exp) {
|
|
119
|
+
// access token expired or expiring soon, refresh synchronously
|
|
120
|
+
await instance.refresh();
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
// access token still valid, fetch session metadata in background
|
|
124
|
+
instance.#refreshMetadata(session);
|
|
125
|
+
}
|
|
126
|
+
if (instance.destroyed) {
|
|
127
|
+
throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
|
|
128
|
+
}
|
|
129
|
+
return instance;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* delete a session server-side without resuming it.
|
|
133
|
+
* useful for cleanup of orphaned sessions.
|
|
134
|
+
* @param session session data to delete
|
|
135
|
+
* @param options session options
|
|
136
|
+
*/
|
|
137
|
+
static async delete(session, options = {}) {
|
|
138
|
+
const instance = new PasswordSession(session, options);
|
|
139
|
+
await instance.logout();
|
|
140
|
+
}
|
|
141
|
+
// --- lifecycle ---
|
|
142
|
+
/** refresh the session tokens */
|
|
143
|
+
async refresh() {
|
|
144
|
+
await this.#refresh();
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* sign out — invalidates session server-side.
|
|
148
|
+
* on success, the session is destroyed and `onDelete` is called.
|
|
149
|
+
* on transient failure (network), `onDeleteFailure` is called and
|
|
150
|
+
* the session stays active for retry.
|
|
151
|
+
* @throws on transient failure when the session couldn't be deleted
|
|
152
|
+
*/
|
|
153
|
+
async logout() {
|
|
154
|
+
let failure = null;
|
|
155
|
+
this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => {
|
|
156
|
+
const response = await this.#server.post('com.atproto.server.deleteSession', {
|
|
157
|
+
as: null,
|
|
158
|
+
headers: {
|
|
159
|
+
authorization: `Bearer ${sessionData.refreshJwt}`,
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
const isExpected = response.status === 401 ||
|
|
164
|
+
response.data.error === 'InvalidToken' ||
|
|
165
|
+
response.data.error === 'ExpiredToken';
|
|
166
|
+
if (!isExpected) {
|
|
167
|
+
// transient error — keep session alive
|
|
168
|
+
failure = new ClientResponseError(response);
|
|
169
|
+
await this.#onDeleteFailure?.(sessionData, failure);
|
|
170
|
+
return sessionData;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// success or expected error → session is gone
|
|
174
|
+
await this.#onDelete?.(sessionData);
|
|
175
|
+
this.#sessionData = null;
|
|
176
|
+
throw new Error(`session has been destroyed`);
|
|
177
|
+
});
|
|
178
|
+
return this.#sessionPromise.then(() => {
|
|
179
|
+
// resolved means logout failed (transient error)
|
|
180
|
+
throw failure;
|
|
181
|
+
}, () => {
|
|
182
|
+
// rejected means session was destroyed (successful logout)
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/** AsyncDisposable — calls `logout()` */
|
|
186
|
+
async [Symbol.asyncDispose]() {
|
|
187
|
+
await this.logout();
|
|
188
|
+
}
|
|
189
|
+
// --- FetchHandlerObject ---
|
|
190
|
+
async handle(pathname, init) {
|
|
191
|
+
const sessionPromise = this.#sessionPromise;
|
|
192
|
+
const sessionData = await sessionPromise;
|
|
193
|
+
const url = new URL(pathname, sessionData.pdsUri ?? sessionData.service);
|
|
194
|
+
const headers = new Headers(init.headers);
|
|
195
|
+
if (headers.has('authorization')) {
|
|
196
|
+
return (0, this.#fetch)(url, init);
|
|
197
|
+
}
|
|
198
|
+
headers.set('authorization', `Bearer ${sessionData.accessJwt}`);
|
|
199
|
+
const initialResponse = await (0, this.#fetch)(url, { ...init, headers });
|
|
200
|
+
if (initialResponse.status !== 401 && !(await isExpiredTokenResponse(initialResponse))) {
|
|
201
|
+
return initialResponse;
|
|
202
|
+
}
|
|
203
|
+
// refresh unless another call already started one
|
|
204
|
+
const refreshPromise = this.#sessionPromise === sessionPromise ? this.#refresh() : this.#sessionPromise;
|
|
205
|
+
const newSessionData = await refreshPromise.catch(() => null);
|
|
206
|
+
if (!newSessionData ||
|
|
207
|
+
newSessionData.accessJwt === sessionData.accessJwt ||
|
|
208
|
+
init.signal?.aborted ||
|
|
209
|
+
init.body instanceof ReadableStream) {
|
|
210
|
+
return initialResponse;
|
|
211
|
+
}
|
|
212
|
+
// cancel initial response to avoid resource leaks
|
|
213
|
+
if (!initialResponse.bodyUsed) {
|
|
214
|
+
await initialResponse.body?.cancel();
|
|
215
|
+
}
|
|
216
|
+
headers.set('authorization', `Bearer ${newSessionData.accessJwt}`);
|
|
217
|
+
return await (0, this.#fetch)(url, { ...init, headers });
|
|
218
|
+
}
|
|
219
|
+
// --- internal ---
|
|
220
|
+
#refresh() {
|
|
221
|
+
this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => {
|
|
222
|
+
const response = await this.#server.post('com.atproto.server.refreshSession', {
|
|
223
|
+
headers: {
|
|
224
|
+
authorization: `Bearer ${sessionData.refreshJwt}`,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
const isExpected = response.status === 401 ||
|
|
229
|
+
response.data.error === 'ExpiredToken' ||
|
|
230
|
+
response.data.error === 'InvalidToken';
|
|
231
|
+
if (isExpected) {
|
|
232
|
+
await this.#onDelete?.(sessionData);
|
|
233
|
+
this.#sessionData = null;
|
|
234
|
+
throw new ClientResponseError(response);
|
|
235
|
+
}
|
|
236
|
+
// transient error — preserve session
|
|
237
|
+
await this.#onUpdateFailure?.(sessionData, new ClientResponseError(response));
|
|
238
|
+
return sessionData;
|
|
239
|
+
}
|
|
240
|
+
// DID must not change during refresh
|
|
241
|
+
if (response.data.did !== sessionData.did) {
|
|
242
|
+
await this.#onDelete?.(sessionData);
|
|
243
|
+
this.#sessionData = null;
|
|
244
|
+
throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
|
|
245
|
+
}
|
|
246
|
+
const newSession = buildSessionData(sessionData.service, { ...sessionData, ...response.data });
|
|
247
|
+
this.#sessionData = newSession;
|
|
248
|
+
await this.#onUpdate?.(newSession);
|
|
249
|
+
return newSession;
|
|
250
|
+
});
|
|
251
|
+
return this.#sessionPromise;
|
|
252
|
+
}
|
|
253
|
+
#refreshMetadata(session) {
|
|
254
|
+
const promise = ok(this.#server.get('com.atproto.server.getSession', {
|
|
255
|
+
headers: {
|
|
256
|
+
authorization: `Bearer ${session.accessJwt}`,
|
|
257
|
+
},
|
|
258
|
+
}));
|
|
259
|
+
promise.then((next) => {
|
|
260
|
+
const existing = this.#sessionData;
|
|
261
|
+
if (!existing || existing.did !== next.did) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const updated = buildSessionData(existing.service, { ...existing, ...next });
|
|
265
|
+
this.#sessionData = updated;
|
|
266
|
+
this.#onUpdate?.(updated);
|
|
267
|
+
}, () => {
|
|
268
|
+
// ignore background metadata fetch errors
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// #endregion
|
|
273
|
+
// #region helpers
|
|
274
|
+
const buildSessionData = (service, raw) => {
|
|
275
|
+
const didDoc = raw.didDoc;
|
|
276
|
+
let pdsUri = raw.pdsUri;
|
|
277
|
+
if (didDoc) {
|
|
278
|
+
pdsUri = getPdsEndpoint(didDoc) ?? pdsUri;
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
service,
|
|
282
|
+
accessJwt: raw.accessJwt,
|
|
283
|
+
refreshJwt: raw.refreshJwt,
|
|
284
|
+
handle: raw.handle,
|
|
285
|
+
did: raw.did,
|
|
286
|
+
pdsUri,
|
|
287
|
+
email: raw.email,
|
|
288
|
+
emailConfirmed: raw.emailConfirmed,
|
|
289
|
+
emailAuthFactor: raw.emailAuthFactor,
|
|
290
|
+
active: raw.active ?? true,
|
|
291
|
+
inactiveStatus: raw.status,
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* parse a login URL into credentials.
|
|
296
|
+
* format: `https://identifier:password@service`
|
|
297
|
+
* @param input URL string or URL object
|
|
298
|
+
* @returns parsed credentials
|
|
299
|
+
*/
|
|
300
|
+
const parseLoginUrl = (input) => {
|
|
301
|
+
const url = typeof input === 'string' ? new URL(input) : input;
|
|
302
|
+
if (url.pathname !== '/') {
|
|
303
|
+
throw new TypeError(`invalid login URL: unexpected pathname`);
|
|
304
|
+
}
|
|
305
|
+
if (url.hash) {
|
|
306
|
+
throw new TypeError(`invalid login URL: unexpected hash`);
|
|
307
|
+
}
|
|
308
|
+
if (url.search) {
|
|
309
|
+
throw new TypeError(`invalid login URL: unexpected search parameters`);
|
|
310
|
+
}
|
|
311
|
+
if (!url.username || !url.password) {
|
|
312
|
+
throw new TypeError(`invalid login URL: missing identifier or password`);
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
service: url.origin,
|
|
316
|
+
identifier: decodeURIComponent(url.username),
|
|
317
|
+
password: decodeURIComponent(url.password),
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
/** decode a JWT token's payload */
|
|
321
|
+
const decodeJwt = (token) => {
|
|
322
|
+
const part = token.split('.')[1];
|
|
323
|
+
if (typeof part !== 'string') {
|
|
324
|
+
throw new Error(`invalid token: missing part 2`);
|
|
325
|
+
}
|
|
326
|
+
let b64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
327
|
+
switch (b64.length % 4) {
|
|
328
|
+
case 0:
|
|
329
|
+
break;
|
|
330
|
+
case 2:
|
|
331
|
+
b64 += '==';
|
|
332
|
+
break;
|
|
333
|
+
case 3:
|
|
334
|
+
b64 += '=';
|
|
335
|
+
break;
|
|
336
|
+
default:
|
|
337
|
+
throw new Error(`invalid token: invalid base64 length`);
|
|
338
|
+
}
|
|
339
|
+
return JSON.parse(atob(b64));
|
|
340
|
+
};
|
|
341
|
+
const isExpiredTokenResponse = async (response) => {
|
|
342
|
+
if (response.status !== 400) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
if (extractContentType(response.headers) !== 'application/json') {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
// this is nasty as it relies heavily on what the PDS returns, but avoiding
|
|
349
|
+
// cloning and reading the request as much as possible is better.
|
|
350
|
+
// {"error":"ExpiredToken","message":"Token has expired"}
|
|
351
|
+
// {"error":"ExpiredToken","message":"Token is expired"}
|
|
352
|
+
if (extractContentLength(response.headers) > 54 * 1.5) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const data = await response.clone().json();
|
|
357
|
+
if (isXRPCErrorPayload(data)) {
|
|
358
|
+
return data.error === 'ExpiredToken';
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
catch { }
|
|
362
|
+
return false;
|
|
363
|
+
};
|
|
364
|
+
const extractContentType = (headers) => {
|
|
365
|
+
return headers.get('content-type')?.split(';')[0]?.trim();
|
|
366
|
+
};
|
|
367
|
+
const extractContentLength = (headers) => {
|
|
368
|
+
return Number(headers.get('content-length') ?? ';');
|
|
369
|
+
};
|
|
370
|
+
// #endregion
|
|
371
|
+
//# sourceMappingURL=password-session.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"password-session.js","sourceRoot":"","sources":["../lib/password-session.ts"],"names":[],"mappings":"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"}
|
package/lib/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './password-session.ts';
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
import type { ComAtprotoServerCreateSession } from '@atcute/atproto';
|
|
2
|
+
import {
|
|
3
|
+
Client,
|
|
4
|
+
ClientResponseError,
|
|
5
|
+
isXRPCErrorPayload,
|
|
6
|
+
ok,
|
|
7
|
+
simpleFetchHandler,
|
|
8
|
+
type FetchHandlerObject,
|
|
9
|
+
} from '@atcute/client';
|
|
10
|
+
import { getPdsEndpoint, type DidDocument } from '@atcute/identity';
|
|
11
|
+
import type { Did } from '@atcute/lexicons';
|
|
12
|
+
|
|
13
|
+
// #region session data
|
|
14
|
+
|
|
15
|
+
/** persistable session data */
|
|
16
|
+
export interface PasswordSessionData {
|
|
17
|
+
/** authentication service URL */
|
|
18
|
+
service: string;
|
|
19
|
+
accessJwt: string;
|
|
20
|
+
refreshJwt: string;
|
|
21
|
+
handle: string;
|
|
22
|
+
did: Did;
|
|
23
|
+
/** PDS endpoint derived from DID document */
|
|
24
|
+
pdsUri?: string;
|
|
25
|
+
email?: string;
|
|
26
|
+
emailConfirmed?: boolean;
|
|
27
|
+
emailAuthFactor?: boolean;
|
|
28
|
+
active: boolean;
|
|
29
|
+
inactiveStatus?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// #endregion
|
|
33
|
+
|
|
34
|
+
// #region options
|
|
35
|
+
|
|
36
|
+
export interface PasswordSessionOptions {
|
|
37
|
+
/** custom fetch implementation */
|
|
38
|
+
fetch?: typeof fetch;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* called when session is successfully created or refreshed with new
|
|
42
|
+
* credentials. use this to persist the updated session.
|
|
43
|
+
* receives `this: PasswordSession` context.
|
|
44
|
+
* @note must not throw
|
|
45
|
+
*/
|
|
46
|
+
onUpdate?: (this: PasswordSession, data: PasswordSessionData) => void | Promise<void>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* called when a session refresh fails due to a transient error (network,
|
|
50
|
+
* server down). the session is preserved — consider retry logic.
|
|
51
|
+
* @note must not throw
|
|
52
|
+
*/
|
|
53
|
+
onUpdateFailure?: (
|
|
54
|
+
this: PasswordSession,
|
|
55
|
+
data: PasswordSessionData,
|
|
56
|
+
error: unknown,
|
|
57
|
+
) => void | Promise<void>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* called when the session is terminated — either explicit logout or
|
|
61
|
+
* server-side invalidation (expired/invalid refresh token).
|
|
62
|
+
* use this to clean up persisted session data.
|
|
63
|
+
* @note must not throw
|
|
64
|
+
*/
|
|
65
|
+
onDelete?: (this: PasswordSession, data: PasswordSessionData) => void | Promise<void>;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* called when logout network request fails due to a transient error.
|
|
69
|
+
* the session stays active locally so you can retry.
|
|
70
|
+
* @note must not throw
|
|
71
|
+
*/
|
|
72
|
+
onDeleteFailure?: (
|
|
73
|
+
this: PasswordSession,
|
|
74
|
+
data: PasswordSessionData,
|
|
75
|
+
error: unknown,
|
|
76
|
+
) => void | Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** credentials for login */
|
|
80
|
+
export interface PasswordSessionLoginCredentials {
|
|
81
|
+
service: string;
|
|
82
|
+
identifier: string;
|
|
83
|
+
password: string;
|
|
84
|
+
/** two-factor authentication code */
|
|
85
|
+
code?: string;
|
|
86
|
+
/** allow signing in even if the account has been taken down */
|
|
87
|
+
allowTakendown?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** options for login — second parameter, behavior config */
|
|
91
|
+
export interface PasswordSessionLoginOptions extends PasswordSessionOptions {
|
|
92
|
+
/** cached session to try resuming before falling back to fresh login */
|
|
93
|
+
session?: PasswordSessionData;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// #endregion
|
|
97
|
+
|
|
98
|
+
// #region class
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* password-based authentication session for AT Protocol services.
|
|
102
|
+
*
|
|
103
|
+
* manages access/refresh token lifecycle, automatic refresh on 401, and
|
|
104
|
+
* session persistence via callbacks. instances are always in an authenticated
|
|
105
|
+
* state — use the static factories for validated construction.
|
|
106
|
+
*
|
|
107
|
+
* for browser-based applications, prefer OAuth-based authentication instead.
|
|
108
|
+
* when using password auth, use app passwords rather than main account credentials.
|
|
109
|
+
*/
|
|
110
|
+
export class PasswordSession implements FetchHandlerObject, AsyncDisposable {
|
|
111
|
+
#sessionData: PasswordSessionData | null;
|
|
112
|
+
#sessionPromise: Promise<PasswordSessionData>;
|
|
113
|
+
#server: Client;
|
|
114
|
+
#fetch: typeof fetch;
|
|
115
|
+
|
|
116
|
+
#onUpdate: PasswordSessionOptions['onUpdate'];
|
|
117
|
+
#onUpdateFailure: PasswordSessionOptions['onUpdateFailure'];
|
|
118
|
+
#onDelete: PasswordSessionOptions['onDelete'];
|
|
119
|
+
#onDeleteFailure: PasswordSessionOptions['onDeleteFailure'];
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* construct with existing session data. tokens refresh lazily on 401.
|
|
123
|
+
* use static `login()` or `resume()` for validated sessions.
|
|
124
|
+
* @param session existing session data
|
|
125
|
+
* @param options session options
|
|
126
|
+
*/
|
|
127
|
+
constructor(session: PasswordSessionData, options: PasswordSessionOptions = {}) {
|
|
128
|
+
this.#sessionData = session;
|
|
129
|
+
this.#sessionPromise = Promise.resolve(session);
|
|
130
|
+
this.#fetch = options.fetch ?? fetch;
|
|
131
|
+
|
|
132
|
+
this.#server = new Client({
|
|
133
|
+
handler: simpleFetchHandler({ service: session.service, fetch: this.#fetch }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
this.#onUpdate = options.onUpdate;
|
|
137
|
+
this.#onUpdateFailure = options.onUpdateFailure;
|
|
138
|
+
this.#onDelete = options.onDelete;
|
|
139
|
+
this.#onDeleteFailure = options.onDeleteFailure;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* account DID
|
|
144
|
+
* @throws if the session has been destroyed
|
|
145
|
+
*/
|
|
146
|
+
get did(): Did {
|
|
147
|
+
return this.session.did;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** whether this session has been destroyed (logged out) */
|
|
151
|
+
get destroyed(): boolean {
|
|
152
|
+
return this.#sessionData === null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* current session data — serialize this for persistence
|
|
157
|
+
* @throws if the session has been destroyed
|
|
158
|
+
*/
|
|
159
|
+
get session(): PasswordSessionData {
|
|
160
|
+
if (this.#sessionData) {
|
|
161
|
+
return this.#sessionData;
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`session has been destroyed`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** URL to dispatch API requests to (PDS from DID doc, or service URL) */
|
|
167
|
+
get dispatchUrl(): string {
|
|
168
|
+
return this.session.pdsUri ?? this.session.service;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- static factories ---
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* authenticate with credentials. optionally tries resuming a cached
|
|
175
|
+
* session first, falling back to fresh createSession on failure.
|
|
176
|
+
* @param credentials login credentials or URL shorthand (`https://handle:pass@service`)
|
|
177
|
+
* @param options login options
|
|
178
|
+
* @returns authenticated session
|
|
179
|
+
*/
|
|
180
|
+
static async login(
|
|
181
|
+
credentials: PasswordSessionLoginCredentials | string | URL,
|
|
182
|
+
options: PasswordSessionLoginOptions = {},
|
|
183
|
+
): Promise<PasswordSession> {
|
|
184
|
+
const creds =
|
|
185
|
+
typeof credentials === 'string' || credentials instanceof URL
|
|
186
|
+
? parseLoginUrl(credentials)
|
|
187
|
+
: credentials;
|
|
188
|
+
|
|
189
|
+
// try cached session first if provided
|
|
190
|
+
if (options.session) {
|
|
191
|
+
try {
|
|
192
|
+
return await PasswordSession.resume(options.session, options);
|
|
193
|
+
} catch {
|
|
194
|
+
// fall through to fresh login
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const _fetch = options.fetch ?? fetch;
|
|
199
|
+
const server = new Client({
|
|
200
|
+
handler: simpleFetchHandler({ service: creds.service, fetch: _fetch }),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const data = await ok(
|
|
204
|
+
server.post('com.atproto.server.createSession', {
|
|
205
|
+
input: {
|
|
206
|
+
identifier: creds.identifier,
|
|
207
|
+
password: creds.password,
|
|
208
|
+
authFactorToken: creds.code,
|
|
209
|
+
allowTakendown: creds.allowTakendown,
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const sessionData = buildSessionData(creds.service, data);
|
|
215
|
+
const session = new PasswordSession(sessionData, options);
|
|
216
|
+
await options.onUpdate?.call(session, sessionData);
|
|
217
|
+
return session;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* resume from persisted session data. if the access token is still valid,
|
|
222
|
+
* returns immediately and refreshes metadata in the background.
|
|
223
|
+
* if expired, refreshes synchronously. throws only if the session is
|
|
224
|
+
* definitively invalid.
|
|
225
|
+
* @param session persisted session data
|
|
226
|
+
* @param options session options
|
|
227
|
+
* @returns resumed session
|
|
228
|
+
*/
|
|
229
|
+
static async resume(
|
|
230
|
+
session: PasswordSessionData,
|
|
231
|
+
options: PasswordSessionOptions = {},
|
|
232
|
+
): Promise<PasswordSession> {
|
|
233
|
+
const instance = new PasswordSession(session, options);
|
|
234
|
+
|
|
235
|
+
const now = Date.now() / 1_000 + 60 * 5;
|
|
236
|
+
const accessToken = decodeJwt(session.accessJwt) as { exp: number };
|
|
237
|
+
|
|
238
|
+
if (now >= accessToken.exp) {
|
|
239
|
+
// access token expired or expiring soon, refresh synchronously
|
|
240
|
+
await instance.refresh();
|
|
241
|
+
} else {
|
|
242
|
+
// access token still valid, fetch session metadata in background
|
|
243
|
+
instance.#refreshMetadata(session);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (instance.destroyed) {
|
|
247
|
+
throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return instance;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* delete a session server-side without resuming it.
|
|
255
|
+
* useful for cleanup of orphaned sessions.
|
|
256
|
+
* @param session session data to delete
|
|
257
|
+
* @param options session options
|
|
258
|
+
*/
|
|
259
|
+
static async delete(session: PasswordSessionData, options: PasswordSessionOptions = {}): Promise<void> {
|
|
260
|
+
const instance = new PasswordSession(session, options);
|
|
261
|
+
await instance.logout();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// --- lifecycle ---
|
|
265
|
+
|
|
266
|
+
/** refresh the session tokens */
|
|
267
|
+
async refresh(): Promise<void> {
|
|
268
|
+
await this.#refresh();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* sign out — invalidates session server-side.
|
|
273
|
+
* on success, the session is destroyed and `onDelete` is called.
|
|
274
|
+
* on transient failure (network), `onDeleteFailure` is called and
|
|
275
|
+
* the session stays active for retry.
|
|
276
|
+
* @throws on transient failure when the session couldn't be deleted
|
|
277
|
+
*/
|
|
278
|
+
async logout(): Promise<void> {
|
|
279
|
+
let failure: unknown = null;
|
|
280
|
+
|
|
281
|
+
this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => {
|
|
282
|
+
const response = await this.#server.post('com.atproto.server.deleteSession', {
|
|
283
|
+
as: null,
|
|
284
|
+
headers: {
|
|
285
|
+
authorization: `Bearer ${sessionData.refreshJwt}`,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
const isExpected =
|
|
291
|
+
response.status === 401 ||
|
|
292
|
+
response.data.error === 'InvalidToken' ||
|
|
293
|
+
response.data.error === 'ExpiredToken';
|
|
294
|
+
|
|
295
|
+
if (!isExpected) {
|
|
296
|
+
// transient error — keep session alive
|
|
297
|
+
failure = new ClientResponseError(response);
|
|
298
|
+
await this.#onDeleteFailure?.(sessionData, failure);
|
|
299
|
+
return sessionData;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// success or expected error → session is gone
|
|
304
|
+
await this.#onDelete?.(sessionData);
|
|
305
|
+
this.#sessionData = null;
|
|
306
|
+
throw new Error(`session has been destroyed`);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
return this.#sessionPromise.then(
|
|
310
|
+
() => {
|
|
311
|
+
// resolved means logout failed (transient error)
|
|
312
|
+
throw failure!;
|
|
313
|
+
},
|
|
314
|
+
() => {
|
|
315
|
+
// rejected means session was destroyed (successful logout)
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** AsyncDisposable — calls `logout()` */
|
|
321
|
+
async [Symbol.asyncDispose](): Promise<void> {
|
|
322
|
+
await this.logout();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// --- FetchHandlerObject ---
|
|
326
|
+
|
|
327
|
+
async handle(pathname: string, init: RequestInit): Promise<Response> {
|
|
328
|
+
const sessionPromise = this.#sessionPromise;
|
|
329
|
+
const sessionData = await sessionPromise;
|
|
330
|
+
|
|
331
|
+
const url = new URL(pathname, sessionData.pdsUri ?? sessionData.service);
|
|
332
|
+
const headers = new Headers(init.headers);
|
|
333
|
+
|
|
334
|
+
if (headers.has('authorization')) {
|
|
335
|
+
return (0, this.#fetch)(url, init);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
headers.set('authorization', `Bearer ${sessionData.accessJwt}`);
|
|
339
|
+
|
|
340
|
+
const initialResponse = await (0, this.#fetch)(url, { ...init, headers });
|
|
341
|
+
|
|
342
|
+
if (initialResponse.status !== 401 && !(await isExpiredTokenResponse(initialResponse))) {
|
|
343
|
+
return initialResponse;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// refresh unless another call already started one
|
|
347
|
+
const refreshPromise = this.#sessionPromise === sessionPromise ? this.#refresh() : this.#sessionPromise;
|
|
348
|
+
|
|
349
|
+
const newSessionData = await refreshPromise.catch(() => null);
|
|
350
|
+
|
|
351
|
+
if (
|
|
352
|
+
!newSessionData ||
|
|
353
|
+
newSessionData.accessJwt === sessionData.accessJwt ||
|
|
354
|
+
init.signal?.aborted ||
|
|
355
|
+
init.body instanceof ReadableStream
|
|
356
|
+
) {
|
|
357
|
+
return initialResponse;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// cancel initial response to avoid resource leaks
|
|
361
|
+
if (!initialResponse.bodyUsed) {
|
|
362
|
+
await initialResponse.body?.cancel();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
headers.set('authorization', `Bearer ${newSessionData.accessJwt}`);
|
|
366
|
+
return await (0, this.#fetch)(url, { ...init, headers });
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// --- internal ---
|
|
370
|
+
|
|
371
|
+
#refresh(): Promise<PasswordSessionData> {
|
|
372
|
+
this.#sessionPromise = this.#sessionPromise.then(async (sessionData) => {
|
|
373
|
+
const response = await this.#server.post('com.atproto.server.refreshSession', {
|
|
374
|
+
headers: {
|
|
375
|
+
authorization: `Bearer ${sessionData.refreshJwt}`,
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
if (!response.ok) {
|
|
380
|
+
const isExpected =
|
|
381
|
+
response.status === 401 ||
|
|
382
|
+
response.data.error === 'ExpiredToken' ||
|
|
383
|
+
response.data.error === 'InvalidToken';
|
|
384
|
+
|
|
385
|
+
if (isExpected) {
|
|
386
|
+
await this.#onDelete?.(sessionData);
|
|
387
|
+
this.#sessionData = null;
|
|
388
|
+
throw new ClientResponseError(response);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// transient error — preserve session
|
|
392
|
+
await this.#onUpdateFailure?.(sessionData, new ClientResponseError(response));
|
|
393
|
+
return sessionData;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// DID must not change during refresh
|
|
397
|
+
if (response.data.did !== sessionData.did) {
|
|
398
|
+
await this.#onDelete?.(sessionData);
|
|
399
|
+
this.#sessionData = null;
|
|
400
|
+
throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const newSession = buildSessionData(sessionData.service, { ...sessionData, ...response.data });
|
|
404
|
+
this.#sessionData = newSession;
|
|
405
|
+
await this.#onUpdate?.(newSession);
|
|
406
|
+
return newSession;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return this.#sessionPromise;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#refreshMetadata(session: PasswordSessionData): void {
|
|
413
|
+
const promise = ok(
|
|
414
|
+
this.#server.get('com.atproto.server.getSession', {
|
|
415
|
+
headers: {
|
|
416
|
+
authorization: `Bearer ${session.accessJwt}`,
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
promise.then(
|
|
422
|
+
(next) => {
|
|
423
|
+
const existing = this.#sessionData;
|
|
424
|
+
if (!existing || existing.did !== next.did) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const updated = buildSessionData(existing.service, { ...existing, ...next });
|
|
429
|
+
this.#sessionData = updated;
|
|
430
|
+
this.#onUpdate?.(updated);
|
|
431
|
+
},
|
|
432
|
+
() => {
|
|
433
|
+
// ignore background metadata fetch errors
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// #endregion
|
|
440
|
+
|
|
441
|
+
// #region helpers
|
|
442
|
+
|
|
443
|
+
const buildSessionData = (
|
|
444
|
+
service: string,
|
|
445
|
+
raw: ComAtprotoServerCreateSession.$output & { pdsUri?: string },
|
|
446
|
+
): PasswordSessionData => {
|
|
447
|
+
const didDoc = raw.didDoc as DidDocument | undefined;
|
|
448
|
+
|
|
449
|
+
let pdsUri = raw.pdsUri;
|
|
450
|
+
if (didDoc) {
|
|
451
|
+
pdsUri = getPdsEndpoint(didDoc) ?? pdsUri;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
service,
|
|
456
|
+
accessJwt: raw.accessJwt,
|
|
457
|
+
refreshJwt: raw.refreshJwt,
|
|
458
|
+
handle: raw.handle,
|
|
459
|
+
did: raw.did,
|
|
460
|
+
pdsUri,
|
|
461
|
+
email: raw.email,
|
|
462
|
+
emailConfirmed: raw.emailConfirmed,
|
|
463
|
+
emailAuthFactor: raw.emailAuthFactor,
|
|
464
|
+
active: raw.active ?? true,
|
|
465
|
+
inactiveStatus: raw.status,
|
|
466
|
+
};
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* parse a login URL into credentials.
|
|
471
|
+
* format: `https://identifier:password@service`
|
|
472
|
+
* @param input URL string or URL object
|
|
473
|
+
* @returns parsed credentials
|
|
474
|
+
*/
|
|
475
|
+
const parseLoginUrl = (input: string | URL): PasswordSessionLoginCredentials => {
|
|
476
|
+
const url = typeof input === 'string' ? new URL(input) : input;
|
|
477
|
+
|
|
478
|
+
if (url.pathname !== '/') {
|
|
479
|
+
throw new TypeError(`invalid login URL: unexpected pathname`);
|
|
480
|
+
}
|
|
481
|
+
if (url.hash) {
|
|
482
|
+
throw new TypeError(`invalid login URL: unexpected hash`);
|
|
483
|
+
}
|
|
484
|
+
if (url.search) {
|
|
485
|
+
throw new TypeError(`invalid login URL: unexpected search parameters`);
|
|
486
|
+
}
|
|
487
|
+
if (!url.username || !url.password) {
|
|
488
|
+
throw new TypeError(`invalid login URL: missing identifier or password`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return {
|
|
492
|
+
service: url.origin,
|
|
493
|
+
identifier: decodeURIComponent(url.username),
|
|
494
|
+
password: decodeURIComponent(url.password),
|
|
495
|
+
};
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/** decode a JWT token's payload */
|
|
499
|
+
const decodeJwt = (token: string): unknown => {
|
|
500
|
+
const part = token.split('.')[1];
|
|
501
|
+
if (typeof part !== 'string') {
|
|
502
|
+
throw new Error(`invalid token: missing part 2`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
let b64 = part.replace(/-/g, '+').replace(/_/g, '/');
|
|
506
|
+
switch (b64.length % 4) {
|
|
507
|
+
case 0:
|
|
508
|
+
break;
|
|
509
|
+
case 2:
|
|
510
|
+
b64 += '==';
|
|
511
|
+
break;
|
|
512
|
+
case 3:
|
|
513
|
+
b64 += '=';
|
|
514
|
+
break;
|
|
515
|
+
default:
|
|
516
|
+
throw new Error(`invalid token: invalid base64 length`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return JSON.parse(atob(b64));
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
const isExpiredTokenResponse = async (response: Response): Promise<boolean> => {
|
|
523
|
+
if (response.status !== 400) {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (extractContentType(response.headers) !== 'application/json') {
|
|
528
|
+
return false;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// this is nasty as it relies heavily on what the PDS returns, but avoiding
|
|
532
|
+
// cloning and reading the request as much as possible is better.
|
|
533
|
+
|
|
534
|
+
// {"error":"ExpiredToken","message":"Token has expired"}
|
|
535
|
+
// {"error":"ExpiredToken","message":"Token is expired"}
|
|
536
|
+
if (extractContentLength(response.headers) > 54 * 1.5) {
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
try {
|
|
541
|
+
const data = await response.clone().json();
|
|
542
|
+
if (isXRPCErrorPayload(data)) {
|
|
543
|
+
return data.error === 'ExpiredToken';
|
|
544
|
+
}
|
|
545
|
+
} catch {}
|
|
546
|
+
|
|
547
|
+
return false;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const extractContentType = (headers: Headers) => {
|
|
551
|
+
return headers.get('content-type')?.split(';')[0]?.trim();
|
|
552
|
+
};
|
|
553
|
+
const extractContentLength = (headers: Headers) => {
|
|
554
|
+
return Number(headers.get('content-length') ?? ';');
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// #endregion
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atcute/password-session",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "password-based session management for AT Protocol",
|
|
5
|
+
"license": "0BSD",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/mary-ext/atcute",
|
|
8
|
+
"directory": "packages/clients/password-session"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"lib/",
|
|
13
|
+
"!lib/**/*.bench.ts",
|
|
14
|
+
"!lib/**/*.test.ts"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@atcute/client": "^4.2.1",
|
|
25
|
+
"@atcute/identity": "^1.1.3",
|
|
26
|
+
"@atcute/lexicons": "^1.2.9"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"vitest": "^4.0.18",
|
|
30
|
+
"@atcute/atproto": "^3.1.10",
|
|
31
|
+
"@atcute/internal-dev-env": "^1.0.2",
|
|
32
|
+
"@atcute/bluesky": "^3.2.19"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsgo --project tsconfig.build.json",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"prepublish": "rm -rf dist; pnpm run build"
|
|
38
|
+
}
|
|
39
|
+
}
|