@chronicle.app/auth 0.3.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.
@@ -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): Promise<StoredCredentials | null>;
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): Promise<string | null>;
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
- if (!this.fileManager) {
7
- // The same config directory oclif resolves, so the file matches what
8
- // `chronicle auth set` writes from inside a command.
9
- this.fileManager = new FileCredentialManager(chronicleConfigDir());
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
- * Update stored credentials directly (used for token refresh)
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 (returns most recent credential)
45
+ * Retrieve stored credentials for a provider: the given account's, or the
46
+ * most recent sign-in.
39
47
  */
40
- getCredentials(provider: string): Promise<StoredCredentials | null>;
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): Promise<string | null>;
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
- * Get the token refresh URL for a specific provider
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
- console.warn('Invalid credentials file, starting fresh:', error);
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
- // Add new credential to the array
85
- credentials[provider].push(newCredential);
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
- * Update stored credentials directly (used for token refresh)
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
- if (!credentials[provider] || credentials[provider].length === 0) {
94
- credentials[provider] = [updatedCredentials];
95
- }
96
- else {
97
- credentials[provider][0] = updatedCredentials;
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 (returns most recent credential)
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
- return providerCredentials && providerCredentials.length > 0
108
- ? (providerCredentials.at(-1) ?? null)
109
- : null;
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
- console.warn(`Failed to refresh ${provider} token:`, error instanceof Error ? error.message : error);
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
- * Get the token refresh URL for a specific provider
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 = {
@@ -1,4 +1,4 @@
1
- import { OAuthParams, TokenResponse, OAuthConfig } from './types.js';
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
- protected generateState(): string;
85
+ static generateState(): string;
52
86
  /**
53
87
  * Build query string from parameters
54
88
  */
@@ -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 Math.random().toString(36).slice(2, 15) + Math.random().toString(36).slice(2, 15);
180
+ static generateState() {
181
+ return randomBytes(16).toString('base64url');
120
182
  }
121
183
  /**
122
184
  * Build query string from parameters
@@ -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;
@@ -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
- return new Promise((resolve, reject) => {
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;
@@ -1,8 +1,13 @@
1
1
  export declare const TokenHelper: {
2
2
  /**
3
- * Get a valid access token for a provider, with user-friendly error messages
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): Promise<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): Promise<boolean>;
18
+ hasValidCredentials(provider: string, options?: {
19
+ account?: string;
20
+ }): Promise<boolean>;
14
21
  /**
15
22
  * Remove stored credentials (for logout/reset)
16
23
  */
@@ -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
- * Get a valid access token for a provider, with user-friendly error messages
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 hasCredentials = await CredentialManager.hasCredentials(provider);
10
- if (hasCredentials) {
11
- throw new Error(`${provider} credentials are expired and could not be refreshed. Please re-authenticate:\n` +
12
- ` chronicle auth ${provider} --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET`);
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
- else {
15
- throw new Error(`No ${provider} credentials found. Please authenticate first:\n` +
16
- ` chronicle auth ${provider} --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET`);
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 Error(options.errorMessage ??
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.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.3.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": {