@chronicle.app/auth 0.2.0 → 0.4.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/dist/CredentialManager.d.ts +18 -3
- package/dist/CredentialManager.js +21 -9
- package/dist/FileCredentialManager.d.ts +20 -6
- package/dist/FileCredentialManager.js +47 -30
- package/dist/OAuthProvider.d.ts +36 -2
- package/dist/OAuthProvider.js +64 -2
- package/dist/OAuthServer.d.ts +2 -0
- package/dist/OAuthServer.js +6 -1
- package/dist/TokenHelper.d.ts +10 -3
- package/dist/TokenHelper.js +27 -12
- package/dist/resolveCredentials.d.ts +6 -0
- package/dist/resolveCredentials.js +2 -2
- package/dist/types.d.ts +37 -0
- package/package.json +3 -2
|
@@ -9,18 +9,30 @@ export interface StoredCredentials {
|
|
|
9
9
|
clientId?: string;
|
|
10
10
|
clientSecret?: string;
|
|
11
11
|
username?: string;
|
|
12
|
+
/** Which account signed in, for a provider with several. */
|
|
13
|
+
account?: string;
|
|
14
|
+
/** Where the refresh token is exchanged. */
|
|
15
|
+
tokenUrl?: string;
|
|
12
16
|
}
|
|
13
17
|
export declare class CredentialManager {
|
|
14
18
|
private static fileManager;
|
|
19
|
+
private static fileManagerDir;
|
|
15
20
|
private static getFileManager;
|
|
16
21
|
/**
|
|
17
22
|
* Store OAuth credentials in local file
|
|
18
23
|
*/
|
|
19
24
|
static storeCredentials(provider: string, credentials: TokenResponse, clientId?: string, clientSecret?: string): Promise<void>;
|
|
20
25
|
/**
|
|
21
|
-
* Retrieve stored credentials from file
|
|
26
|
+
* Retrieve stored credentials from file: the given account's, or the most
|
|
27
|
+
* recent sign-in
|
|
22
28
|
*/
|
|
23
|
-
static getCredentials(provider: string
|
|
29
|
+
static getCredentials(provider: string, options?: {
|
|
30
|
+
account?: string;
|
|
31
|
+
}): Promise<StoredCredentials | null>;
|
|
32
|
+
/**
|
|
33
|
+
* Every account's stored credentials for a provider
|
|
34
|
+
*/
|
|
35
|
+
static getAllCredentials(provider: string): Promise<StoredCredentials[]>;
|
|
24
36
|
/**
|
|
25
37
|
* Remove credentials from file
|
|
26
38
|
*/
|
|
@@ -32,7 +44,10 @@ export declare class CredentialManager {
|
|
|
32
44
|
/**
|
|
33
45
|
* Get valid access token, checking expiration
|
|
34
46
|
*/
|
|
35
|
-
static getValidToken(provider: string
|
|
47
|
+
static getValidToken(provider: string, options?: {
|
|
48
|
+
account?: string;
|
|
49
|
+
refresh?: boolean;
|
|
50
|
+
}): Promise<string | null>;
|
|
36
51
|
/**
|
|
37
52
|
* List all stored provider credentials
|
|
38
53
|
*/
|
|
@@ -2,11 +2,15 @@ import { chronicleConfigDir } from './configDir.js';
|
|
|
2
2
|
import { FileCredentialManager } from './FileCredentialManager.js';
|
|
3
3
|
export class CredentialManager {
|
|
4
4
|
static fileManager = null;
|
|
5
|
+
static fileManagerDir = null;
|
|
5
6
|
static getFileManager() {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// The same config directory oclif resolves, so the file matches what
|
|
8
|
+
// `chronicle auth set` writes from inside a command. Looked up each time,
|
|
9
|
+
// so a change to CHRONICLE_CONFIG_DIR (a test's own directory) is seen.
|
|
10
|
+
const dir = chronicleConfigDir();
|
|
11
|
+
if (!this.fileManager || this.fileManagerDir !== dir) {
|
|
12
|
+
this.fileManager = new FileCredentialManager(dir);
|
|
13
|
+
this.fileManagerDir = dir;
|
|
10
14
|
}
|
|
11
15
|
return this.fileManager;
|
|
12
16
|
}
|
|
@@ -18,11 +22,19 @@ export class CredentialManager {
|
|
|
18
22
|
await fileManager.storeCredentials(provider, credentials, clientId, clientSecret);
|
|
19
23
|
}
|
|
20
24
|
/**
|
|
21
|
-
* Retrieve stored credentials from file
|
|
25
|
+
* Retrieve stored credentials from file: the given account's, or the most
|
|
26
|
+
* recent sign-in
|
|
22
27
|
*/
|
|
23
|
-
static async getCredentials(provider) {
|
|
28
|
+
static async getCredentials(provider, options = {}) {
|
|
24
29
|
const fileManager = this.getFileManager();
|
|
25
|
-
return fileManager.getCredentials(provider);
|
|
30
|
+
return fileManager.getCredentials(provider, options);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Every account's stored credentials for a provider
|
|
34
|
+
*/
|
|
35
|
+
static async getAllCredentials(provider) {
|
|
36
|
+
const fileManager = this.getFileManager();
|
|
37
|
+
return fileManager.getAllCredentials(provider);
|
|
26
38
|
}
|
|
27
39
|
/**
|
|
28
40
|
* Remove credentials from file
|
|
@@ -41,9 +53,9 @@ export class CredentialManager {
|
|
|
41
53
|
/**
|
|
42
54
|
* Get valid access token, checking expiration
|
|
43
55
|
*/
|
|
44
|
-
static async getValidToken(provider) {
|
|
56
|
+
static async getValidToken(provider, options = {}) {
|
|
45
57
|
const fileManager = this.getFileManager();
|
|
46
|
-
return fileManager.getValidToken(provider);
|
|
58
|
+
return fileManager.getValidToken(provider, options);
|
|
47
59
|
}
|
|
48
60
|
/**
|
|
49
61
|
* List all stored provider credentials
|
|
@@ -9,6 +9,10 @@ export interface StoredCredentials {
|
|
|
9
9
|
clientId?: string;
|
|
10
10
|
clientSecret?: string;
|
|
11
11
|
username?: string;
|
|
12
|
+
/** Which account signed in, for a provider with several. */
|
|
13
|
+
account?: string;
|
|
14
|
+
/** Where the refresh token is exchanged. */
|
|
15
|
+
tokenUrl?: string;
|
|
12
16
|
[key: string]: any;
|
|
13
17
|
}
|
|
14
18
|
export declare class FileCredentialManager {
|
|
@@ -27,17 +31,23 @@ export declare class FileCredentialManager {
|
|
|
27
31
|
*/
|
|
28
32
|
private saveCredentials;
|
|
29
33
|
/**
|
|
30
|
-
* Store OAuth credentials for a provider
|
|
34
|
+
* Store OAuth credentials for a provider. A provider keeps one entry per
|
|
35
|
+
* account: signing in again replaces that account's entry, and a provider
|
|
36
|
+
* that doesn't name accounts keeps just one.
|
|
31
37
|
*/
|
|
32
38
|
storeCredentials(provider: string, tokenResponse: TokenResponse, clientId?: string, clientSecret?: string): Promise<void>;
|
|
33
39
|
/**
|
|
34
|
-
*
|
|
40
|
+
* Replace the stored entry that `previous` was read from (used for token
|
|
41
|
+
* refresh), matched on its account, else its refresh token.
|
|
35
42
|
*/
|
|
36
43
|
private updateStoredCredentials;
|
|
37
44
|
/**
|
|
38
|
-
* Retrieve stored credentials for a provider
|
|
45
|
+
* Retrieve stored credentials for a provider: the given account's, or the
|
|
46
|
+
* most recent sign-in.
|
|
39
47
|
*/
|
|
40
|
-
getCredentials(provider: string
|
|
48
|
+
getCredentials(provider: string, { account }?: {
|
|
49
|
+
account?: string;
|
|
50
|
+
}): Promise<StoredCredentials | null>;
|
|
41
51
|
/**
|
|
42
52
|
* Retrieve all stored credentials for a provider
|
|
43
53
|
*/
|
|
@@ -53,13 +63,17 @@ export declare class FileCredentialManager {
|
|
|
53
63
|
/**
|
|
54
64
|
* Get valid access token, checking expiration
|
|
55
65
|
*/
|
|
56
|
-
getValidToken(provider: string
|
|
66
|
+
getValidToken(provider: string, options?: {
|
|
67
|
+
account?: string;
|
|
68
|
+
refresh?: boolean;
|
|
69
|
+
}): Promise<string | null>;
|
|
57
70
|
/**
|
|
58
71
|
* Refresh an expired token using the refresh token
|
|
59
72
|
*/
|
|
60
73
|
private refreshToken;
|
|
61
74
|
/**
|
|
62
|
-
*
|
|
75
|
+
* The token refresh URL for credentials stored before the token URL was
|
|
76
|
+
* saved with them.
|
|
63
77
|
*/
|
|
64
78
|
private getRefreshUrl;
|
|
65
79
|
/**
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
+
import { createLogger } from '@chronicle.app/logging';
|
|
4
|
+
const logger = createLogger({ scope: 'credentials' });
|
|
3
5
|
export class FileCredentialManager {
|
|
4
6
|
credentialsPath;
|
|
5
7
|
constructor(configDir) {
|
|
@@ -21,7 +23,7 @@ export class FileCredentialManager {
|
|
|
21
23
|
return {};
|
|
22
24
|
}
|
|
23
25
|
// If file is invalid, return empty and log warning
|
|
24
|
-
|
|
26
|
+
logger.warn('Invalid credentials file, starting fresh', { error: String(error) });
|
|
25
27
|
return {};
|
|
26
28
|
}
|
|
27
29
|
}
|
|
@@ -53,14 +55,12 @@ export class FileCredentialManager {
|
|
|
53
55
|
);
|
|
54
56
|
}
|
|
55
57
|
/**
|
|
56
|
-
* Store OAuth credentials for a provider
|
|
58
|
+
* Store OAuth credentials for a provider. A provider keeps one entry per
|
|
59
|
+
* account: signing in again replaces that account's entry, and a provider
|
|
60
|
+
* that doesn't name accounts keeps just one.
|
|
57
61
|
*/
|
|
58
62
|
async storeCredentials(provider, tokenResponse, clientId, clientSecret) {
|
|
59
63
|
const credentials = await this.loadCredentials();
|
|
60
|
-
// Initialize provider array if it doesn't exist
|
|
61
|
-
if (!credentials[provider]) {
|
|
62
|
-
credentials[provider] = [];
|
|
63
|
-
}
|
|
64
64
|
const newCredential = {
|
|
65
65
|
accessToken: tokenResponse.access_token,
|
|
66
66
|
refreshToken: tokenResponse.refresh_token,
|
|
@@ -81,32 +81,45 @@ export class FileCredentialManager {
|
|
|
81
81
|
'provider',
|
|
82
82
|
].includes(key))),
|
|
83
83
|
};
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
const kept = (credentials[provider] ?? []).filter(existing => newCredential.account && existing.account !== newCredential.account);
|
|
85
|
+
// Google sends a refresh token only on the first consent for a client;
|
|
86
|
+
// a later sign-in keeps the one already stored for that account.
|
|
87
|
+
const previous = (credentials[provider] ?? []).find(existing => existing.account === newCredential.account);
|
|
88
|
+
if (!newCredential.refreshToken && previous?.refreshToken) {
|
|
89
|
+
newCredential.refreshToken = previous.refreshToken;
|
|
90
|
+
}
|
|
91
|
+
credentials[provider] = [...kept, newCredential];
|
|
86
92
|
await this.saveCredentials(credentials);
|
|
87
93
|
}
|
|
88
94
|
/**
|
|
89
|
-
*
|
|
95
|
+
* Replace the stored entry that `previous` was read from (used for token
|
|
96
|
+
* refresh), matched on its account, else its refresh token.
|
|
90
97
|
*/
|
|
91
|
-
async updateStoredCredentials(provider, updatedCredentials) {
|
|
98
|
+
async updateStoredCredentials(provider, previous, updatedCredentials) {
|
|
92
99
|
const credentials = await this.loadCredentials();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
const entries = credentials[provider] ?? [];
|
|
101
|
+
const index = entries.findIndex(entry => previous.account
|
|
102
|
+
? entry.account === previous.account
|
|
103
|
+
: entry.refreshToken === previous.refreshToken);
|
|
104
|
+
if (index === -1)
|
|
105
|
+
entries.push(updatedCredentials);
|
|
106
|
+
else
|
|
107
|
+
entries[index] = updatedCredentials;
|
|
108
|
+
credentials[provider] = entries;
|
|
99
109
|
await this.saveCredentials(credentials);
|
|
100
110
|
}
|
|
101
111
|
/**
|
|
102
|
-
* Retrieve stored credentials for a provider
|
|
112
|
+
* Retrieve stored credentials for a provider: the given account's, or the
|
|
113
|
+
* most recent sign-in.
|
|
103
114
|
*/
|
|
104
|
-
async getCredentials(provider) {
|
|
115
|
+
async getCredentials(provider, { account } = {}) {
|
|
105
116
|
const credentials = await this.loadCredentials();
|
|
106
|
-
const providerCredentials = credentials[provider];
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
const providerCredentials = credentials[provider] ?? [];
|
|
118
|
+
if (account) {
|
|
119
|
+
const wanted = account.toLowerCase();
|
|
120
|
+
return providerCredentials.find(entry => entry.account?.toLowerCase() === wanted) ?? null;
|
|
121
|
+
}
|
|
122
|
+
return providerCredentials.at(-1) ?? null;
|
|
110
123
|
}
|
|
111
124
|
/**
|
|
112
125
|
* Retrieve all stored credentials for a provider
|
|
@@ -140,13 +153,13 @@ export class FileCredentialManager {
|
|
|
140
153
|
/**
|
|
141
154
|
* Get valid access token, checking expiration
|
|
142
155
|
*/
|
|
143
|
-
async getValidToken(provider) {
|
|
144
|
-
const credentials = await this.getCredentials(provider);
|
|
156
|
+
async getValidToken(provider, options = {}) {
|
|
157
|
+
const credentials = await this.getCredentials(provider, options);
|
|
145
158
|
if (!credentials) {
|
|
146
159
|
return null;
|
|
147
160
|
}
|
|
148
|
-
// If token is not expired, return it
|
|
149
|
-
if (!this.isTokenExpired(credentials)) {
|
|
161
|
+
// If token is not expired, return it, unless the caller saw it refused.
|
|
162
|
+
if (!options.refresh && !this.isTokenExpired(credentials)) {
|
|
150
163
|
return credentials.accessToken;
|
|
151
164
|
}
|
|
152
165
|
// Token is expired - try to refresh if we have refresh token and client secret
|
|
@@ -158,7 +171,9 @@ export class FileCredentialManager {
|
|
|
158
171
|
}
|
|
159
172
|
}
|
|
160
173
|
catch (error) {
|
|
161
|
-
|
|
174
|
+
logger.warn(`Failed to refresh the ${provider} token`, {
|
|
175
|
+
error: error instanceof Error ? error.message : String(error),
|
|
176
|
+
});
|
|
162
177
|
}
|
|
163
178
|
}
|
|
164
179
|
return null;
|
|
@@ -170,7 +185,7 @@ export class FileCredentialManager {
|
|
|
170
185
|
if (!credentials.refreshToken || !credentials.clientId || !credentials.clientSecret) {
|
|
171
186
|
return null;
|
|
172
187
|
}
|
|
173
|
-
const refreshUrl = this.getRefreshUrl(provider);
|
|
188
|
+
const refreshUrl = credentials.tokenUrl ?? this.getRefreshUrl(provider);
|
|
174
189
|
if (!refreshUrl) {
|
|
175
190
|
return null;
|
|
176
191
|
}
|
|
@@ -197,9 +212,10 @@ export class FileCredentialManager {
|
|
|
197
212
|
accessToken: tokenData.access_token,
|
|
198
213
|
refreshToken: tokenData.refresh_token || credentials.refreshToken,
|
|
199
214
|
expiresIn: tokenData.expires_in,
|
|
215
|
+
scope: tokenData.scope ?? credentials.scope,
|
|
200
216
|
createdAt: new Date().toISOString(),
|
|
201
217
|
};
|
|
202
|
-
await this.updateStoredCredentials(provider, updatedCredentials);
|
|
218
|
+
await this.updateStoredCredentials(provider, credentials, updatedCredentials);
|
|
203
219
|
return tokenData.access_token;
|
|
204
220
|
}
|
|
205
221
|
catch (error) {
|
|
@@ -207,7 +223,8 @@ export class FileCredentialManager {
|
|
|
207
223
|
}
|
|
208
224
|
}
|
|
209
225
|
/**
|
|
210
|
-
*
|
|
226
|
+
* The token refresh URL for credentials stored before the token URL was
|
|
227
|
+
* saved with them.
|
|
211
228
|
*/
|
|
212
229
|
getRefreshUrl(provider) {
|
|
213
230
|
const refreshUrls = {
|
package/dist/OAuthProvider.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ClientCredentials, OAuthParams, OAuthConfig, ProviderSetupContext, TokenResponse } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Base OAuth2 authorization-code flow. A standard provider only declares the
|
|
4
4
|
* endpoints, scopes, and credential style:
|
|
@@ -14,6 +14,10 @@ import { OAuthParams, TokenResponse, OAuthConfig } from './types.js';
|
|
|
14
14
|
*
|
|
15
15
|
* A provider whose protocol deviates from OAuth2 (e.g. Last.fm's signed
|
|
16
16
|
* auth.getSession) overrides buildAuthUrl / exchangeCodeForToken instead.
|
|
17
|
+
*
|
|
18
|
+
* A provider whose people register their own OAuth client can walk them
|
|
19
|
+
* through it in `setup()`, which `chronicle auth login` runs when no client
|
|
20
|
+
* is given on the command line.
|
|
17
21
|
*/
|
|
18
22
|
export declare abstract class OAuthProvider {
|
|
19
23
|
static providerId: string;
|
|
@@ -29,9 +33,39 @@ export declare abstract class OAuthProvider {
|
|
|
29
33
|
static tokenAuthStyle: 'basic-header' | 'body';
|
|
30
34
|
/** Provider-specific additions to the authorization URL. */
|
|
31
35
|
static extraAuthParams: Record<string, string>;
|
|
36
|
+
/** Send a PKCE challenge with the authorization and its verifier with the exchange. */
|
|
37
|
+
static pkce: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Scopes by a name a person can pass to `chronicle auth login --add`, for
|
|
40
|
+
* a provider whose sources each need their own (`gmail`, `calendar`).
|
|
41
|
+
* Every login also asks for `scopes`.
|
|
42
|
+
*/
|
|
43
|
+
static scopeSets: Record<string, string[]>;
|
|
44
|
+
/** The sets a first login asks for when none are named. */
|
|
45
|
+
static defaultScopeSets: string[];
|
|
46
|
+
/** Printed under the sign-in summary: what to run next. */
|
|
47
|
+
static signedInHint?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Find or make the OAuth client to sign in with, walking the person
|
|
50
|
+
* through whatever the provider's console needs. Run by `chronicle auth
|
|
51
|
+
* login` when no `--client-id` is given.
|
|
52
|
+
*/
|
|
53
|
+
static setup?(context: ProviderSetupContext): Promise<ClientCredentials>;
|
|
32
54
|
protected params: OAuthParams;
|
|
33
55
|
constructor(params: OAuthParams);
|
|
34
56
|
static getConfig(): OAuthConfig;
|
|
57
|
+
/** The scopes for the named sets, with the provider's own, without repeats. */
|
|
58
|
+
static scopesFor(sets: string[]): string[];
|
|
59
|
+
/** The named sets a granted scope string covers in full. */
|
|
60
|
+
static scopeSetsIn(granted: string | undefined): string[];
|
|
61
|
+
/** A PKCE verifier: 43 URL-safe characters. */
|
|
62
|
+
static createCodeVerifier(): string;
|
|
63
|
+
/**
|
|
64
|
+
* Which account signed in, from the token response, for a provider that
|
|
65
|
+
* says (an OpenID `id_token`). Several accounts of one provider are kept
|
|
66
|
+
* apart by it.
|
|
67
|
+
*/
|
|
68
|
+
protected accountFrom(_response: any): string | undefined;
|
|
35
69
|
private get providerClass();
|
|
36
70
|
/**
|
|
37
71
|
* Build the authorization URL where the user will be redirected
|
|
@@ -48,7 +82,7 @@ export declare abstract class OAuthProvider {
|
|
|
48
82
|
/**
|
|
49
83
|
* Generate a random state parameter for CSRF protection
|
|
50
84
|
*/
|
|
51
|
-
|
|
85
|
+
static generateState(): string;
|
|
52
86
|
/**
|
|
53
87
|
* Build query string from parameters
|
|
54
88
|
*/
|
package/dist/OAuthProvider.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
|
+
import { EXIT_CODES, ExtractorError } from '@chronicle.app/logging';
|
|
3
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
4
|
/**
|
|
3
5
|
* Base OAuth2 authorization-code flow. A standard provider only declares the
|
|
4
6
|
* endpoints, scopes, and credential style:
|
|
@@ -14,6 +16,10 @@ import axios from 'axios';
|
|
|
14
16
|
*
|
|
15
17
|
* A provider whose protocol deviates from OAuth2 (e.g. Last.fm's signed
|
|
16
18
|
* auth.getSession) overrides buildAuthUrl / exchangeCodeForToken instead.
|
|
19
|
+
*
|
|
20
|
+
* A provider whose people register their own OAuth client can walk them
|
|
21
|
+
* through it in `setup()`, which `chronicle auth login` runs when no client
|
|
22
|
+
* is given on the command line.
|
|
17
23
|
*/
|
|
18
24
|
export class OAuthProvider {
|
|
19
25
|
static providerId;
|
|
@@ -29,6 +35,18 @@ export class OAuthProvider {
|
|
|
29
35
|
static tokenAuthStyle = 'body';
|
|
30
36
|
/** Provider-specific additions to the authorization URL. */
|
|
31
37
|
static extraAuthParams = {};
|
|
38
|
+
/** Send a PKCE challenge with the authorization and its verifier with the exchange. */
|
|
39
|
+
static pkce = false;
|
|
40
|
+
/**
|
|
41
|
+
* Scopes by a name a person can pass to `chronicle auth login --add`, for
|
|
42
|
+
* a provider whose sources each need their own (`gmail`, `calendar`).
|
|
43
|
+
* Every login also asks for `scopes`.
|
|
44
|
+
*/
|
|
45
|
+
static scopeSets = {};
|
|
46
|
+
/** The sets a first login asks for when none are named. */
|
|
47
|
+
static defaultScopeSets = [];
|
|
48
|
+
/** Printed under the sign-in summary: what to run next. */
|
|
49
|
+
static signedInHint;
|
|
32
50
|
params;
|
|
33
51
|
constructor(params) {
|
|
34
52
|
this.params = params;
|
|
@@ -42,6 +60,38 @@ export class OAuthProvider {
|
|
|
42
60
|
requiresClientSecret: this.requiresClientSecret,
|
|
43
61
|
};
|
|
44
62
|
}
|
|
63
|
+
/** The scopes for the named sets, with the provider's own, without repeats. */
|
|
64
|
+
static scopesFor(sets) {
|
|
65
|
+
const unknown = sets.filter(set => !this.scopeSets[set]);
|
|
66
|
+
if (unknown.length > 0) {
|
|
67
|
+
const known = Object.keys(this.scopeSets);
|
|
68
|
+
throw new ExtractorError(`Unknown access "${unknown[0]}" for ${this.providerId}`, {
|
|
69
|
+
code: 'unknown-scope-set',
|
|
70
|
+
exitCode: EXIT_CODES.usage,
|
|
71
|
+
hint: `Use ${known.length > 1 ? `${known.slice(0, -1).join(', ')}, or ${known.at(-1)}` : known[0]}.`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return [...new Set([...this.scopes, ...sets.flatMap(set => this.scopeSets[set])])];
|
|
75
|
+
}
|
|
76
|
+
/** The named sets a granted scope string covers in full. */
|
|
77
|
+
static scopeSetsIn(granted) {
|
|
78
|
+
const have = new Set((granted ?? '').split(/\s+/).filter(Boolean));
|
|
79
|
+
return Object.entries(this.scopeSets)
|
|
80
|
+
.filter(([, scopes]) => scopes.every(scope => have.has(scope)))
|
|
81
|
+
.map(([set]) => set);
|
|
82
|
+
}
|
|
83
|
+
/** A PKCE verifier: 43 URL-safe characters. */
|
|
84
|
+
static createCodeVerifier() {
|
|
85
|
+
return randomBytes(32).toString('base64url');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Which account signed in, from the token response, for a provider that
|
|
89
|
+
* says (an OpenID `id_token`). Several accounts of one provider are kept
|
|
90
|
+
* apart by it.
|
|
91
|
+
*/
|
|
92
|
+
accountFrom(_response) {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
45
95
|
get providerClass() {
|
|
46
96
|
return this.constructor;
|
|
47
97
|
}
|
|
@@ -60,6 +110,12 @@ export class OAuthProvider {
|
|
|
60
110
|
params.scope = scope;
|
|
61
111
|
}
|
|
62
112
|
Object.assign(params, cls.extraAuthParams);
|
|
113
|
+
if (cls.pkce && this.params.codeVerifier) {
|
|
114
|
+
params.code_challenge = createHash('sha256')
|
|
115
|
+
.update(this.params.codeVerifier)
|
|
116
|
+
.digest('base64url');
|
|
117
|
+
params.code_challenge_method = 'S256';
|
|
118
|
+
}
|
|
63
119
|
if (this.params.state) {
|
|
64
120
|
params.state = this.params.state;
|
|
65
121
|
}
|
|
@@ -76,6 +132,9 @@ export class OAuthProvider {
|
|
|
76
132
|
code,
|
|
77
133
|
redirect_uri: this.params.redirectUri,
|
|
78
134
|
});
|
|
135
|
+
if (cls.pkce && this.params.codeVerifier) {
|
|
136
|
+
data.set('code_verifier', this.params.codeVerifier);
|
|
137
|
+
}
|
|
79
138
|
const headers = {
|
|
80
139
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
81
140
|
};
|
|
@@ -88,6 +147,7 @@ export class OAuthProvider {
|
|
|
88
147
|
data.set('client_secret', this.params.clientSecret);
|
|
89
148
|
}
|
|
90
149
|
const response = await this.postToken(cls.tokenUrl, data, { headers });
|
|
150
|
+
const account = this.accountFrom(response);
|
|
91
151
|
return {
|
|
92
152
|
provider: cls.providerId,
|
|
93
153
|
access_token: response.access_token,
|
|
@@ -97,6 +157,8 @@ export class OAuthProvider {
|
|
|
97
157
|
expires_in: response.expires_in,
|
|
98
158
|
scope: response.scope,
|
|
99
159
|
created_at: new Date().toISOString(),
|
|
160
|
+
...(account && { account }),
|
|
161
|
+
tokenUrl: cls.tokenUrl,
|
|
100
162
|
};
|
|
101
163
|
}
|
|
102
164
|
catch (error) {
|
|
@@ -115,8 +177,8 @@ export class OAuthProvider {
|
|
|
115
177
|
/**
|
|
116
178
|
* Generate a random state parameter for CSRF protection
|
|
117
179
|
*/
|
|
118
|
-
generateState() {
|
|
119
|
-
return
|
|
180
|
+
static generateState() {
|
|
181
|
+
return randomBytes(16).toString('base64url');
|
|
120
182
|
}
|
|
121
183
|
/**
|
|
122
184
|
* Build query string from parameters
|
package/dist/OAuthServer.d.ts
CHANGED
|
@@ -8,7 +8,9 @@ export declare class OAuthServer {
|
|
|
8
8
|
private failure?;
|
|
9
9
|
private resolve?;
|
|
10
10
|
private reject?;
|
|
11
|
+
private pending?;
|
|
11
12
|
start(preferredPort?: number, timeoutMs?: number): Promise<number>;
|
|
13
|
+
/** The callback, however many callers wait for it: they share one promise. */
|
|
12
14
|
waitForCallback(): Promise<AuthorizationResult>;
|
|
13
15
|
stop(error?: Error): void;
|
|
14
16
|
getCallbackUrl(): string;
|
package/dist/OAuthServer.js
CHANGED
|
@@ -8,11 +8,13 @@ export class OAuthServer {
|
|
|
8
8
|
failure;
|
|
9
9
|
resolve;
|
|
10
10
|
reject;
|
|
11
|
+
pending;
|
|
11
12
|
async start(preferredPort = 0, timeoutMs = 5 * 60 * 1000) {
|
|
12
13
|
if (this.server)
|
|
13
14
|
throw new Error('OAuth server already started');
|
|
14
15
|
this.result = undefined;
|
|
15
16
|
this.failure = undefined;
|
|
17
|
+
this.pending = undefined;
|
|
16
18
|
const server = http.createServer((request, response) => {
|
|
17
19
|
const url = new URL(request.url || '/', 'http://127.0.0.1');
|
|
18
20
|
if (url.pathname !== '/callback') {
|
|
@@ -54,6 +56,7 @@ export class OAuthServer {
|
|
|
54
56
|
throw error;
|
|
55
57
|
}
|
|
56
58
|
}
|
|
59
|
+
/** The callback, however many callers wait for it: they share one promise. */
|
|
57
60
|
async waitForCallback() {
|
|
58
61
|
if (this.result)
|
|
59
62
|
return this.result;
|
|
@@ -61,10 +64,11 @@ export class OAuthServer {
|
|
|
61
64
|
throw this.failure;
|
|
62
65
|
if (!this.server)
|
|
63
66
|
throw new Error('Server not started');
|
|
64
|
-
|
|
67
|
+
this.pending ??= new Promise((resolve, reject) => {
|
|
65
68
|
this.resolve = resolve;
|
|
66
69
|
this.reject = reject;
|
|
67
70
|
});
|
|
71
|
+
return this.pending;
|
|
68
72
|
}
|
|
69
73
|
stop(error = new Error('OAuth authorization cancelled')) {
|
|
70
74
|
if (this.timeout)
|
|
@@ -76,6 +80,7 @@ export class OAuthServer {
|
|
|
76
80
|
}
|
|
77
81
|
this.resolve = undefined;
|
|
78
82
|
this.reject = undefined;
|
|
83
|
+
this.pending = undefined;
|
|
79
84
|
this.server?.closeAllConnections();
|
|
80
85
|
this.server?.close();
|
|
81
86
|
this.server = undefined;
|
package/dist/TokenHelper.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
export declare const TokenHelper: {
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* A valid access token for a provider, with user-friendly error messages.
|
|
4
|
+
* `refresh` gets a new one even when the stored one looks current: the
|
|
5
|
+
* source refused it.
|
|
4
6
|
*/
|
|
5
|
-
getValidToken(provider: string
|
|
7
|
+
getValidToken(provider: string, { account, refresh }?: {
|
|
8
|
+
account?: string;
|
|
9
|
+
refresh?: boolean;
|
|
10
|
+
}): Promise<string>;
|
|
6
11
|
/**
|
|
7
12
|
* Get token or use provided access token, with preference for keychain
|
|
8
13
|
*/
|
|
@@ -10,7 +15,9 @@ export declare const TokenHelper: {
|
|
|
10
15
|
/**
|
|
11
16
|
* Check if valid credentials exist for a provider
|
|
12
17
|
*/
|
|
13
|
-
hasValidCredentials(provider: string
|
|
18
|
+
hasValidCredentials(provider: string, options?: {
|
|
19
|
+
account?: string;
|
|
20
|
+
}): Promise<boolean>;
|
|
14
21
|
/**
|
|
15
22
|
* Remove stored credentials (for logout/reset)
|
|
16
23
|
*/
|
package/dist/TokenHelper.js
CHANGED
|
@@ -1,20 +1,35 @@
|
|
|
1
|
+
import { AuthRequired } from '@chronicle.app/logging';
|
|
1
2
|
import { CredentialManager } from './CredentialManager.js';
|
|
3
|
+
import { OAuthProviderRegistry } from './ProviderRegistry.js';
|
|
2
4
|
export const TokenHelper = {
|
|
3
5
|
/**
|
|
4
|
-
*
|
|
6
|
+
* A valid access token for a provider, with user-friendly error messages.
|
|
7
|
+
* `refresh` gets a new one even when the stored one looks current: the
|
|
8
|
+
* source refused it.
|
|
5
9
|
*/
|
|
6
|
-
async getValidToken(provider) {
|
|
7
|
-
const token = await CredentialManager.getValidToken(provider);
|
|
10
|
+
async getValidToken(provider, { account, refresh } = {}) {
|
|
11
|
+
const token = await CredentialManager.getValidToken(provider, { account, refresh });
|
|
8
12
|
if (!token) {
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
const stored = await CredentialManager.getCredentials(provider, { account });
|
|
14
|
+
// A client stored from an earlier sign-in is reused, and a provider
|
|
15
|
+
// with guided setup makes one, so the command needs no client flags then.
|
|
16
|
+
const login = stored?.clientId || OAuthProviderRegistry.get(provider)?.setup
|
|
17
|
+
? `chronicle auth login ${provider}`
|
|
18
|
+
: `chronicle auth login ${provider} --client-id <id> --client-secret <secret>`;
|
|
19
|
+
if (stored) {
|
|
20
|
+
throw new AuthRequired(`${provider} credentials expired and couldn't be refreshed`, {
|
|
21
|
+
hint: `Run \`${login}\` to sign in again.`,
|
|
22
|
+
});
|
|
13
23
|
}
|
|
14
|
-
|
|
15
|
-
throw new
|
|
16
|
-
`
|
|
24
|
+
if (account && (await CredentialManager.hasCredentials(provider))) {
|
|
25
|
+
throw new AuthRequired(`No ${provider} credentials for that account`, {
|
|
26
|
+
hint: `Run \`chronicle auth login ${provider}\` and sign in with that account.`,
|
|
27
|
+
fields: { account },
|
|
28
|
+
});
|
|
17
29
|
}
|
|
30
|
+
throw new AuthRequired(`No ${provider} credentials`, {
|
|
31
|
+
hint: `Run \`${login}\` to sign in.`,
|
|
32
|
+
});
|
|
18
33
|
}
|
|
19
34
|
return token;
|
|
20
35
|
},
|
|
@@ -32,9 +47,9 @@ export const TokenHelper = {
|
|
|
32
47
|
/**
|
|
33
48
|
* Check if valid credentials exist for a provider
|
|
34
49
|
*/
|
|
35
|
-
async hasValidCredentials(provider) {
|
|
50
|
+
async hasValidCredentials(provider, options = {}) {
|
|
36
51
|
try {
|
|
37
|
-
const token = await CredentialManager.getValidToken(provider);
|
|
52
|
+
const token = await CredentialManager.getValidToken(provider, options);
|
|
38
53
|
return token !== null;
|
|
39
54
|
}
|
|
40
55
|
catch {
|
|
@@ -24,6 +24,12 @@ export interface ResolveCredentialsOptions {
|
|
|
24
24
|
* exact user-facing message (auth command, credential-creation URL, …).
|
|
25
25
|
*/
|
|
26
26
|
errorMessage?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The next step printed under the error. Defaults to `chronicle auth set`,
|
|
29
|
+
* which stores the static secrets this path reads; an OAuth source whose
|
|
30
|
+
* token comes from `chronicle auth login` says so here.
|
|
31
|
+
*/
|
|
32
|
+
hint?: string;
|
|
27
33
|
}
|
|
28
34
|
/** Pure resolution core, separated for testing. */
|
|
29
35
|
export declare function pickCredentialFields<F extends string>(provider: string, stored: object | null, fields: Record<F, CredentialFieldSpec>, options?: ResolveCredentialsOptions): Record<F, string | undefined>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AuthRequired } from '@chronicle.app/logging';
|
|
1
2
|
import { CredentialManager } from './CredentialManager.js';
|
|
2
3
|
/** Pure resolution core, separated for testing. */
|
|
3
4
|
export function pickCredentialFields(provider, stored, fields, options = {}) {
|
|
@@ -22,8 +23,7 @@ export function pickCredentialFields(provider, stored, fields, options = {}) {
|
|
|
22
23
|
missing.push(name);
|
|
23
24
|
}
|
|
24
25
|
if (missing.length > 0) {
|
|
25
|
-
throw new
|
|
26
|
-
`${provider} credentials are missing ${missing.join(', ')}. Authenticate with: chronicle auth ${provider}`);
|
|
26
|
+
throw new AuthRequired(options.errorMessage ?? `${provider} credentials are missing ${missing.join(', ')}`, { hint: options.hint ?? `Run \`chronicle auth set ${provider}\` to store a token.` });
|
|
27
27
|
}
|
|
28
28
|
return resolved;
|
|
29
29
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import type { Logger } from '@chronicle.app/logging';
|
|
1
2
|
export interface OAuthParams {
|
|
2
3
|
clientId: string;
|
|
3
4
|
clientSecret: string;
|
|
4
5
|
redirectUri: string;
|
|
5
6
|
scopes?: string[];
|
|
6
7
|
state?: string;
|
|
8
|
+
/** PKCE verifier, when the provider uses PKCE; its challenge goes in the auth URL. */
|
|
9
|
+
codeVerifier?: string;
|
|
7
10
|
}
|
|
8
11
|
export interface TokenResponse {
|
|
9
12
|
provider: string;
|
|
@@ -14,6 +17,10 @@ export interface TokenResponse {
|
|
|
14
17
|
scope?: string;
|
|
15
18
|
created_at: string;
|
|
16
19
|
username?: string;
|
|
20
|
+
/** Which of the provider's accounts signed in (an email), when it says. */
|
|
21
|
+
account?: string;
|
|
22
|
+
/** Where the refresh token is exchanged, saved so refresh needs no provider table. */
|
|
23
|
+
tokenUrl?: string;
|
|
17
24
|
}
|
|
18
25
|
export interface OAuthConfig {
|
|
19
26
|
providerId: string;
|
|
@@ -29,4 +36,34 @@ export interface AuthorizationResult {
|
|
|
29
36
|
error?: string;
|
|
30
37
|
error_description?: string;
|
|
31
38
|
}
|
|
39
|
+
/** An OAuth client a person registered with the provider. */
|
|
40
|
+
export interface ClientCredentials {
|
|
41
|
+
clientId: string;
|
|
42
|
+
clientSecret?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* What the CLI hands a provider's `setup()`: ways to talk to the person
|
|
46
|
+
* without the provider depending on the CLI.
|
|
47
|
+
*/
|
|
48
|
+
export interface ProviderSetupContext {
|
|
49
|
+
/** The command's logger; hints and notices go through it. */
|
|
50
|
+
logger: Logger;
|
|
51
|
+
/** Chronicle's config directory, for state the setup keeps between runs. */
|
|
52
|
+
configDir: string;
|
|
53
|
+
/** Named scope sets this login asks for (`--add`), so setup can enable their APIs. */
|
|
54
|
+
scopeSets: string[];
|
|
55
|
+
/** Whether a person is at a terminal to answer. */
|
|
56
|
+
interactive: boolean;
|
|
57
|
+
/** Start over instead of reusing what a previous setup made. */
|
|
58
|
+
fresh: boolean;
|
|
59
|
+
/** Open a page in the browser; with `--no-browser` it only prints the URL. */
|
|
60
|
+
openUrl(url: string): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Ask the person something and return what they typed (empty for just
|
|
63
|
+
* Enter). Aborting `signal` withdraws the question.
|
|
64
|
+
*/
|
|
65
|
+
ask(prompt: string, signal?: AbortSignal): Promise<string>;
|
|
66
|
+
/** Cancelled with Ctrl-C. */
|
|
67
|
+
signal: AbortSignal;
|
|
68
|
+
}
|
|
32
69
|
//# sourceMappingURL=types.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chronicle.app/auth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"files": [
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"type": "module",
|
|
20
20
|
"description": "Standalone Chronicle credential and OAuth services",
|
|
21
21
|
"devDependencies": {
|
|
22
|
-
"@chronicle.app/tsconfig": "0.
|
|
22
|
+
"@chronicle.app/tsconfig": "0.4.0",
|
|
23
23
|
"@types/node": "^22.10.1",
|
|
24
24
|
"typescript": "^5.7.2"
|
|
25
25
|
},
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"registry": "https://registry.npmjs.org/"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
+
"@chronicle.app/logging": "0.4.0",
|
|
40
41
|
"axios": "^1.7.9"
|
|
41
42
|
},
|
|
42
43
|
"engines": {
|