@chronicle.app/auth 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,4 @@
1
+ # Chronicle auth
2
+
3
+ Standalone credential files, OAuth providers, callback server, and token helpers.
4
+ Uses `CHRONICLE_CONFIG_DIR` or the platform Chronicle config directory.
@@ -0,0 +1,49 @@
1
+ import { TokenResponse } from './types.js';
2
+ export interface StoredCredentials {
3
+ accessToken: string;
4
+ refreshToken?: string;
5
+ tokenType: string;
6
+ expiresIn?: number;
7
+ scope?: string;
8
+ createdAt: string;
9
+ clientId?: string;
10
+ clientSecret?: string;
11
+ username?: string;
12
+ }
13
+ export declare class CredentialManager {
14
+ private static fileManager;
15
+ private static getFileManager;
16
+ /**
17
+ * Store OAuth credentials in local file
18
+ */
19
+ static storeCredentials(provider: string, credentials: TokenResponse, clientId?: string, clientSecret?: string): Promise<void>;
20
+ /**
21
+ * Retrieve stored credentials from file
22
+ */
23
+ static getCredentials(provider: string): Promise<StoredCredentials | null>;
24
+ /**
25
+ * Remove credentials from file
26
+ */
27
+ static removeCredentials(provider: string): Promise<void>;
28
+ /**
29
+ * Check if stored access token is expired
30
+ */
31
+ static isTokenExpired(credentials: StoredCredentials): boolean;
32
+ /**
33
+ * Get valid access token, checking expiration
34
+ */
35
+ static getValidToken(provider: string): Promise<string | null>;
36
+ /**
37
+ * List all stored provider credentials
38
+ */
39
+ static listProviders(): Promise<string[]>;
40
+ /**
41
+ * Check if credentials exist for a provider
42
+ */
43
+ static hasCredentials(provider: string): Promise<boolean>;
44
+ /**
45
+ * Get the path where credentials are stored (for user reference)
46
+ */
47
+ static getCredentialsPath(): string;
48
+ }
49
+ //# sourceMappingURL=CredentialManager.d.ts.map
@@ -0,0 +1,69 @@
1
+ import { chronicleConfigDir } from './configDir.js';
2
+ import { FileCredentialManager } from './FileCredentialManager.js';
3
+ export class CredentialManager {
4
+ static fileManager = null;
5
+ 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());
10
+ }
11
+ return this.fileManager;
12
+ }
13
+ /**
14
+ * Store OAuth credentials in local file
15
+ */
16
+ static async storeCredentials(provider, credentials, clientId, clientSecret) {
17
+ const fileManager = this.getFileManager();
18
+ await fileManager.storeCredentials(provider, credentials, clientId, clientSecret);
19
+ }
20
+ /**
21
+ * Retrieve stored credentials from file
22
+ */
23
+ static async getCredentials(provider) {
24
+ const fileManager = this.getFileManager();
25
+ return fileManager.getCredentials(provider);
26
+ }
27
+ /**
28
+ * Remove credentials from file
29
+ */
30
+ static async removeCredentials(provider) {
31
+ const fileManager = this.getFileManager();
32
+ await fileManager.removeCredentials(provider);
33
+ }
34
+ /**
35
+ * Check if stored access token is expired
36
+ */
37
+ static isTokenExpired(credentials) {
38
+ const fileManager = this.getFileManager();
39
+ return fileManager.isTokenExpired(credentials);
40
+ }
41
+ /**
42
+ * Get valid access token, checking expiration
43
+ */
44
+ static async getValidToken(provider) {
45
+ const fileManager = this.getFileManager();
46
+ return fileManager.getValidToken(provider);
47
+ }
48
+ /**
49
+ * List all stored provider credentials
50
+ */
51
+ static async listProviders() {
52
+ const fileManager = this.getFileManager();
53
+ return fileManager.listProviders();
54
+ }
55
+ /**
56
+ * Check if credentials exist for a provider
57
+ */
58
+ static async hasCredentials(provider) {
59
+ const fileManager = this.getFileManager();
60
+ return fileManager.hasCredentials(provider);
61
+ }
62
+ /**
63
+ * Get the path where credentials are stored (for user reference)
64
+ */
65
+ static getCredentialsPath() {
66
+ const fileManager = this.getFileManager();
67
+ return fileManager.getCredentialsPath();
68
+ }
69
+ }
@@ -0,0 +1,78 @@
1
+ import { TokenResponse } from './types.js';
2
+ export interface StoredCredentials {
3
+ accessToken: string;
4
+ refreshToken?: string;
5
+ tokenType: string;
6
+ expiresIn?: number;
7
+ scope?: string;
8
+ createdAt: string;
9
+ clientId?: string;
10
+ clientSecret?: string;
11
+ username?: string;
12
+ [key: string]: any;
13
+ }
14
+ export declare class FileCredentialManager {
15
+ private credentialsPath;
16
+ constructor(configDir: string);
17
+ /**
18
+ * Load all credentials from file
19
+ */
20
+ private loadCredentials;
21
+ /**
22
+ * Migrate old credential format (single objects) to new format (arrays)
23
+ */
24
+ private migrateCredentialsFormat;
25
+ /**
26
+ * Save all credentials to file
27
+ */
28
+ private saveCredentials;
29
+ /**
30
+ * Store OAuth credentials for a provider
31
+ */
32
+ storeCredentials(provider: string, tokenResponse: TokenResponse, clientId?: string, clientSecret?: string): Promise<void>;
33
+ /**
34
+ * Update stored credentials directly (used for token refresh)
35
+ */
36
+ private updateStoredCredentials;
37
+ /**
38
+ * Retrieve stored credentials for a provider (returns most recent credential)
39
+ */
40
+ getCredentials(provider: string): Promise<StoredCredentials | null>;
41
+ /**
42
+ * Retrieve all stored credentials for a provider
43
+ */
44
+ getAllCredentials(provider: string): Promise<StoredCredentials[]>;
45
+ /**
46
+ * Remove credentials for a provider
47
+ */
48
+ removeCredentials(provider: string): Promise<void>;
49
+ /**
50
+ * Check if stored access token is expired
51
+ */
52
+ isTokenExpired(credentials: StoredCredentials): boolean;
53
+ /**
54
+ * Get valid access token, checking expiration
55
+ */
56
+ getValidToken(provider: string): Promise<string | null>;
57
+ /**
58
+ * Refresh an expired token using the refresh token
59
+ */
60
+ private refreshToken;
61
+ /**
62
+ * Get the token refresh URL for a specific provider
63
+ */
64
+ private getRefreshUrl;
65
+ /**
66
+ * List all providers with stored credentials
67
+ */
68
+ listProviders(): Promise<string[]>;
69
+ /**
70
+ * Check if credentials exist for a provider
71
+ */
72
+ hasCredentials(provider: string): Promise<boolean>;
73
+ /**
74
+ * Get the path where credentials are stored (for user reference)
75
+ */
76
+ getCredentialsPath(): string;
77
+ }
78
+ //# sourceMappingURL=FileCredentialManager.d.ts.map
@@ -0,0 +1,241 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ export class FileCredentialManager {
4
+ credentialsPath;
5
+ constructor(configDir) {
6
+ this.credentialsPath = join(configDir, 'credentials.json');
7
+ }
8
+ /**
9
+ * Load all credentials from file
10
+ */
11
+ async loadCredentials() {
12
+ try {
13
+ const credentialsText = await fs.readFile(this.credentialsPath, 'utf-8');
14
+ const parsed = JSON.parse(credentialsText);
15
+ // Migrate old format to new array format
16
+ return this.migrateCredentialsFormat(parsed);
17
+ }
18
+ catch (error) {
19
+ if (error.code === 'ENOENT') {
20
+ // Credentials file doesn't exist, return empty
21
+ return {};
22
+ }
23
+ // If file is invalid, return empty and log warning
24
+ console.warn('Invalid credentials file, starting fresh:', error);
25
+ return {};
26
+ }
27
+ }
28
+ /**
29
+ * Migrate old credential format (single objects) to new format (arrays)
30
+ */
31
+ migrateCredentialsFormat(credentials) {
32
+ const migrated = {};
33
+ for (const [provider, creds] of Object.entries(credentials)) {
34
+ if (Array.isArray(creds)) {
35
+ // Already in new format
36
+ migrated[provider] = creds;
37
+ }
38
+ else {
39
+ // Old format - convert to array
40
+ migrated[provider] = [creds];
41
+ }
42
+ }
43
+ return migrated;
44
+ }
45
+ /**
46
+ * Save all credentials to file
47
+ */
48
+ async saveCredentials(credentials) {
49
+ // Ensure config directory exists
50
+ await fs.mkdir(dirname(this.credentialsPath), { recursive: true });
51
+ // Write credentials file with proper formatting and permissions
52
+ await fs.writeFile(this.credentialsPath, JSON.stringify(credentials, null, 2), { mode: 0o600 } // Restrict to user read/write only
53
+ );
54
+ }
55
+ /**
56
+ * Store OAuth credentials for a provider
57
+ */
58
+ async storeCredentials(provider, tokenResponse, clientId, clientSecret) {
59
+ const credentials = await this.loadCredentials();
60
+ // Initialize provider array if it doesn't exist
61
+ if (!credentials[provider]) {
62
+ credentials[provider] = [];
63
+ }
64
+ const newCredential = {
65
+ accessToken: tokenResponse.access_token,
66
+ refreshToken: tokenResponse.refresh_token,
67
+ tokenType: tokenResponse.token_type,
68
+ expiresIn: tokenResponse.expires_in,
69
+ scope: tokenResponse.scope,
70
+ createdAt: tokenResponse.created_at,
71
+ clientId,
72
+ clientSecret,
73
+ // Include any additional fields from the token response
74
+ ...Object.fromEntries(Object.entries(tokenResponse).filter(([key]) => ![
75
+ 'access_token',
76
+ 'refresh_token',
77
+ 'token_type',
78
+ 'expires_in',
79
+ 'scope',
80
+ 'created_at',
81
+ 'provider',
82
+ ].includes(key))),
83
+ };
84
+ // Add new credential to the array
85
+ credentials[provider].push(newCredential);
86
+ await this.saveCredentials(credentials);
87
+ }
88
+ /**
89
+ * Update stored credentials directly (used for token refresh)
90
+ */
91
+ async updateStoredCredentials(provider, updatedCredentials) {
92
+ 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
+ }
99
+ await this.saveCredentials(credentials);
100
+ }
101
+ /**
102
+ * Retrieve stored credentials for a provider (returns most recent credential)
103
+ */
104
+ async getCredentials(provider) {
105
+ const credentials = await this.loadCredentials();
106
+ const providerCredentials = credentials[provider];
107
+ return providerCredentials && providerCredentials.length > 0
108
+ ? (providerCredentials.at(-1) ?? null)
109
+ : null;
110
+ }
111
+ /**
112
+ * Retrieve all stored credentials for a provider
113
+ */
114
+ async getAllCredentials(provider) {
115
+ const credentials = await this.loadCredentials();
116
+ return credentials[provider] || [];
117
+ }
118
+ /**
119
+ * Remove credentials for a provider
120
+ */
121
+ async removeCredentials(provider) {
122
+ const credentials = await this.loadCredentials();
123
+ delete credentials[provider];
124
+ await this.saveCredentials(credentials);
125
+ }
126
+ /**
127
+ * Check if stored access token is expired
128
+ */
129
+ isTokenExpired(credentials) {
130
+ if (!credentials.expiresIn) {
131
+ return false; // No expiration info, assume valid
132
+ }
133
+ const createdAt = new Date(credentials.createdAt).getTime();
134
+ const expiresAt = createdAt + credentials.expiresIn * 1000;
135
+ const now = Date.now();
136
+ // Consider expired if less than 5 minutes remaining
137
+ const bufferTime = 5 * 60 * 1000; // 5 minutes
138
+ return now >= expiresAt - bufferTime;
139
+ }
140
+ /**
141
+ * Get valid access token, checking expiration
142
+ */
143
+ async getValidToken(provider) {
144
+ const credentials = await this.getCredentials(provider);
145
+ if (!credentials) {
146
+ return null;
147
+ }
148
+ // If token is not expired, return it
149
+ if (!this.isTokenExpired(credentials)) {
150
+ return credentials.accessToken;
151
+ }
152
+ // Token is expired - try to refresh if we have refresh token and client secret
153
+ if (credentials.refreshToken && credentials.clientId && credentials.clientSecret) {
154
+ try {
155
+ const refreshedToken = await this.refreshToken(provider, credentials);
156
+ if (refreshedToken) {
157
+ return refreshedToken;
158
+ }
159
+ }
160
+ catch (error) {
161
+ console.warn(`Failed to refresh ${provider} token:`, error instanceof Error ? error.message : error);
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ /**
167
+ * Refresh an expired token using the refresh token
168
+ */
169
+ async refreshToken(provider, credentials) {
170
+ if (!credentials.refreshToken || !credentials.clientId || !credentials.clientSecret) {
171
+ return null;
172
+ }
173
+ const refreshUrl = this.getRefreshUrl(provider);
174
+ if (!refreshUrl) {
175
+ return null;
176
+ }
177
+ try {
178
+ const response = await fetch(refreshUrl, {
179
+ method: 'POST',
180
+ headers: {
181
+ 'Content-Type': 'application/x-www-form-urlencoded',
182
+ Authorization: `Basic ${Buffer.from(`${credentials.clientId}:${credentials.clientSecret}`).toString('base64')}`,
183
+ },
184
+ body: new URLSearchParams({
185
+ grant_type: 'refresh_token',
186
+ refresh_token: credentials.refreshToken,
187
+ }),
188
+ });
189
+ if (!response.ok) {
190
+ const errorText = await response.text();
191
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
192
+ }
193
+ const tokenData = (await response.json());
194
+ // Update stored credentials with new token
195
+ const updatedCredentials = {
196
+ ...credentials,
197
+ accessToken: tokenData.access_token,
198
+ refreshToken: tokenData.refresh_token || credentials.refreshToken,
199
+ expiresIn: tokenData.expires_in,
200
+ createdAt: new Date().toISOString(),
201
+ };
202
+ await this.updateStoredCredentials(provider, updatedCredentials);
203
+ return tokenData.access_token;
204
+ }
205
+ catch (error) {
206
+ throw new Error(`Token refresh failed: ${error instanceof Error ? error.message : error}`);
207
+ }
208
+ }
209
+ /**
210
+ * Get the token refresh URL for a specific provider
211
+ */
212
+ getRefreshUrl(provider) {
213
+ const refreshUrls = {
214
+ spotify: 'https://accounts.spotify.com/api/token',
215
+ // Google's token endpoint accepts client_secret_basic, so the shared
216
+ // Basic-auth refresh above works for it.
217
+ youtube: 'https://oauth2.googleapis.com/token',
218
+ };
219
+ return refreshUrls[provider] || null;
220
+ }
221
+ /**
222
+ * List all providers with stored credentials
223
+ */
224
+ async listProviders() {
225
+ const credentials = await this.loadCredentials();
226
+ return Object.keys(credentials);
227
+ }
228
+ /**
229
+ * Check if credentials exist for a provider
230
+ */
231
+ async hasCredentials(provider) {
232
+ const credentials = await this.getCredentials(provider);
233
+ return credentials !== null;
234
+ }
235
+ /**
236
+ * Get the path where credentials are stored (for user reference)
237
+ */
238
+ getCredentialsPath() {
239
+ return this.credentialsPath;
240
+ }
241
+ }
@@ -0,0 +1,57 @@
1
+ import { OAuthParams, TokenResponse, OAuthConfig } from './types.js';
2
+ /**
3
+ * Base OAuth2 authorization-code flow. A standard provider only declares the
4
+ * endpoints, scopes, and credential style:
5
+ *
6
+ * class MyProvider extends OAuthProvider {
7
+ * static override providerId = 'my-source';
8
+ * static override authorizationUrl = 'https://example.com/authorize';
9
+ * static override tokenUrl = 'https://example.com/token';
10
+ * static override scopes = ['read'];
11
+ * static override tokenAuthStyle = 'basic-header'; // or 'body' (default)
12
+ * static override extraAuthParams = { access_type: 'offline' };
13
+ * }
14
+ *
15
+ * A provider whose protocol deviates from OAuth2 (e.g. Last.fm's signed
16
+ * auth.getSession) overrides buildAuthUrl / exchangeCodeForToken instead.
17
+ */
18
+ export declare abstract class OAuthProvider {
19
+ static providerId: string;
20
+ static authorizationUrl: string;
21
+ static tokenUrl: string;
22
+ static scopes: string[];
23
+ static requiresClientSecret: boolean;
24
+ /**
25
+ * Where the client credentials go on the token exchange: 'basic-header'
26
+ * sends an Authorization: Basic header; 'body' (default) puts
27
+ * client_id/client_secret in the form body.
28
+ */
29
+ static tokenAuthStyle: 'basic-header' | 'body';
30
+ /** Provider-specific additions to the authorization URL. */
31
+ static extraAuthParams: Record<string, string>;
32
+ protected params: OAuthParams;
33
+ constructor(params: OAuthParams);
34
+ static getConfig(): OAuthConfig;
35
+ private get providerClass();
36
+ /**
37
+ * Build the authorization URL where the user will be redirected
38
+ */
39
+ buildAuthUrl(): string;
40
+ /**
41
+ * Exchange authorization code for access tokens
42
+ */
43
+ exchangeCodeForToken(code: string): Promise<TokenResponse>;
44
+ /** One POST to the token endpoint; overridable so tests can mock HTTP. */
45
+ protected postToken(url: string, data: URLSearchParams, config: {
46
+ headers: Record<string, string>;
47
+ }): Promise<any>;
48
+ /**
49
+ * Generate a random state parameter for CSRF protection
50
+ */
51
+ protected generateState(): string;
52
+ /**
53
+ * Build query string from parameters
54
+ */
55
+ protected buildQueryString(params: Record<string, string>): string;
56
+ }
57
+ //# sourceMappingURL=OAuthProvider.d.ts.map
@@ -0,0 +1,129 @@
1
+ import axios from 'axios';
2
+ /**
3
+ * Base OAuth2 authorization-code flow. A standard provider only declares the
4
+ * endpoints, scopes, and credential style:
5
+ *
6
+ * class MyProvider extends OAuthProvider {
7
+ * static override providerId = 'my-source';
8
+ * static override authorizationUrl = 'https://example.com/authorize';
9
+ * static override tokenUrl = 'https://example.com/token';
10
+ * static override scopes = ['read'];
11
+ * static override tokenAuthStyle = 'basic-header'; // or 'body' (default)
12
+ * static override extraAuthParams = { access_type: 'offline' };
13
+ * }
14
+ *
15
+ * A provider whose protocol deviates from OAuth2 (e.g. Last.fm's signed
16
+ * auth.getSession) overrides buildAuthUrl / exchangeCodeForToken instead.
17
+ */
18
+ export class OAuthProvider {
19
+ static providerId;
20
+ static authorizationUrl;
21
+ static tokenUrl;
22
+ static scopes = [];
23
+ static requiresClientSecret = true;
24
+ /**
25
+ * Where the client credentials go on the token exchange: 'basic-header'
26
+ * sends an Authorization: Basic header; 'body' (default) puts
27
+ * client_id/client_secret in the form body.
28
+ */
29
+ static tokenAuthStyle = 'body';
30
+ /** Provider-specific additions to the authorization URL. */
31
+ static extraAuthParams = {};
32
+ params;
33
+ constructor(params) {
34
+ this.params = params;
35
+ }
36
+ static getConfig() {
37
+ return {
38
+ providerId: this.providerId,
39
+ authorizationUrl: this.authorizationUrl,
40
+ tokenUrl: this.tokenUrl,
41
+ scopes: this.scopes,
42
+ requiresClientSecret: this.requiresClientSecret,
43
+ };
44
+ }
45
+ get providerClass() {
46
+ return this.constructor;
47
+ }
48
+ /**
49
+ * Build the authorization URL where the user will be redirected
50
+ */
51
+ buildAuthUrl() {
52
+ const cls = this.providerClass;
53
+ const params = {
54
+ client_id: this.params.clientId,
55
+ response_type: 'code',
56
+ redirect_uri: this.params.redirectUri,
57
+ };
58
+ const scope = (this.params.scopes?.length ? this.params.scopes : cls.scopes).join(' ');
59
+ if (scope) {
60
+ params.scope = scope;
61
+ }
62
+ Object.assign(params, cls.extraAuthParams);
63
+ if (this.params.state) {
64
+ params.state = this.params.state;
65
+ }
66
+ return `${cls.authorizationUrl}?${this.buildQueryString(params)}`;
67
+ }
68
+ /**
69
+ * Exchange authorization code for access tokens
70
+ */
71
+ async exchangeCodeForToken(code) {
72
+ const cls = this.providerClass;
73
+ try {
74
+ const data = new URLSearchParams({
75
+ grant_type: 'authorization_code',
76
+ code,
77
+ redirect_uri: this.params.redirectUri,
78
+ });
79
+ const headers = {
80
+ 'Content-Type': 'application/x-www-form-urlencoded',
81
+ };
82
+ if (cls.tokenAuthStyle === 'basic-header') {
83
+ const credentials = Buffer.from(`${this.params.clientId}:${this.params.clientSecret}`).toString('base64');
84
+ headers.Authorization = `Basic ${credentials}`;
85
+ }
86
+ else {
87
+ data.set('client_id', this.params.clientId);
88
+ data.set('client_secret', this.params.clientSecret);
89
+ }
90
+ const response = await this.postToken(cls.tokenUrl, data, { headers });
91
+ return {
92
+ provider: cls.providerId,
93
+ access_token: response.access_token,
94
+ refresh_token: response.refresh_token,
95
+ // Some providers (foursquare) omit token_type on the exchange
96
+ token_type: response.token_type ?? 'Bearer',
97
+ expires_in: response.expires_in,
98
+ scope: response.scope,
99
+ created_at: new Date().toISOString(),
100
+ };
101
+ }
102
+ catch (error) {
103
+ if (axios.isAxiosError(error)) {
104
+ const message = error.response?.data?.error_description || error.response?.data?.error || error.message;
105
+ throw new Error(`Failed to exchange code for tokens: ${message}`);
106
+ }
107
+ throw error;
108
+ }
109
+ }
110
+ /** One POST to the token endpoint; overridable so tests can mock HTTP. */
111
+ async postToken(url, data, config) {
112
+ const response = await axios.post(url, data, config);
113
+ return response.data;
114
+ }
115
+ /**
116
+ * Generate a random state parameter for CSRF protection
117
+ */
118
+ generateState() {
119
+ return Math.random().toString(36).slice(2, 15) + Math.random().toString(36).slice(2, 15);
120
+ }
121
+ /**
122
+ * Build query string from parameters
123
+ */
124
+ buildQueryString(params) {
125
+ return Object.entries(params)
126
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
127
+ .join('&');
128
+ }
129
+ }
@@ -0,0 +1,16 @@
1
+ import type { AuthorizationResult } from './types.js';
2
+ /** A loopback-only callback server. Its pending callback always settles on shutdown. */
3
+ export declare class OAuthServer {
4
+ private server?;
5
+ private port?;
6
+ private timeout?;
7
+ private result?;
8
+ private failure?;
9
+ private resolve?;
10
+ private reject?;
11
+ start(preferredPort?: number, timeoutMs?: number): Promise<number>;
12
+ waitForCallback(): Promise<AuthorizationResult>;
13
+ stop(error?: Error): void;
14
+ getCallbackUrl(): string;
15
+ }
16
+ //# sourceMappingURL=OAuthServer.d.ts.map
@@ -0,0 +1,89 @@
1
+ import http from 'node:http';
2
+ /** A loopback-only callback server. Its pending callback always settles on shutdown. */
3
+ export class OAuthServer {
4
+ server;
5
+ port;
6
+ timeout;
7
+ result;
8
+ failure;
9
+ resolve;
10
+ reject;
11
+ async start(preferredPort = 0, timeoutMs = 5 * 60 * 1000) {
12
+ if (this.server)
13
+ throw new Error('OAuth server already started');
14
+ this.result = undefined;
15
+ this.failure = undefined;
16
+ const server = http.createServer((request, response) => {
17
+ const url = new URL(request.url || '/', 'http://127.0.0.1');
18
+ if (url.pathname !== '/callback') {
19
+ response.writeHead(url.pathname === '/' ? 200 : 404, { 'Content-Type': 'text/plain' });
20
+ response.end(url.pathname === '/' ? 'Waiting for authorization.' : 'Not found');
21
+ return;
22
+ }
23
+ const result = {};
24
+ for (const key of ['code', 'token', 'state', 'error', 'error_description']) {
25
+ const value = url.searchParams.get(key);
26
+ if (value !== null)
27
+ result[key] = value;
28
+ }
29
+ this.result = result;
30
+ response.writeHead(result.error ? 400 : 200, { 'Content-Type': 'text/plain' });
31
+ response.end(result.error
32
+ ? 'Authorization failed. Return to your terminal.'
33
+ : 'Authorization received. Return to your terminal.');
34
+ this.resolve?.(result);
35
+ });
36
+ this.server = server;
37
+ try {
38
+ await new Promise((resolve, reject) => {
39
+ server.once('error', reject);
40
+ server.listen(preferredPort, '127.0.0.1', () => {
41
+ server.removeListener('error', reject);
42
+ resolve();
43
+ });
44
+ });
45
+ const address = server.address();
46
+ if (!address || typeof address === 'string')
47
+ throw new Error('Failed to start OAuth server');
48
+ this.port = address.port;
49
+ this.timeout = setTimeout(() => this.stop(new Error('OAuth authorization timed out')), timeoutMs);
50
+ return this.port;
51
+ }
52
+ catch (error) {
53
+ this.stop();
54
+ throw error;
55
+ }
56
+ }
57
+ async waitForCallback() {
58
+ if (this.result)
59
+ return this.result;
60
+ if (this.failure)
61
+ throw this.failure;
62
+ if (!this.server)
63
+ throw new Error('Server not started');
64
+ return new Promise((resolve, reject) => {
65
+ this.resolve = resolve;
66
+ this.reject = reject;
67
+ });
68
+ }
69
+ stop(error = new Error('OAuth authorization cancelled')) {
70
+ if (this.timeout)
71
+ clearTimeout(this.timeout);
72
+ this.timeout = undefined;
73
+ if (!this.result) {
74
+ this.failure = error;
75
+ this.reject?.(error);
76
+ }
77
+ this.resolve = undefined;
78
+ this.reject = undefined;
79
+ this.server?.closeAllConnections();
80
+ this.server?.close();
81
+ this.server = undefined;
82
+ this.port = undefined;
83
+ }
84
+ getCallbackUrl() {
85
+ if (!this.port)
86
+ throw new Error('Server not started');
87
+ return `http://127.0.0.1:${this.port}/callback`;
88
+ }
89
+ }
@@ -0,0 +1,25 @@
1
+ import { OAuthProvider } from './OAuthProvider.js';
2
+ export declare class OAuthProviderRegistry {
3
+ private static providers;
4
+ /**
5
+ * Register an OAuth provider
6
+ */
7
+ static register(provider: typeof OAuthProvider): void;
8
+ /**
9
+ * Get a provider by ID
10
+ */
11
+ static get(providerId: string): typeof OAuthProvider | undefined;
12
+ /**
13
+ * Get all registered provider IDs
14
+ */
15
+ static getProviderIds(): string[];
16
+ /**
17
+ * Check if a provider is registered
18
+ */
19
+ static has(providerId: string): boolean;
20
+ /**
21
+ * Get all providers
22
+ */
23
+ static getAll(): Map<string, typeof OAuthProvider>;
24
+ }
25
+ //# sourceMappingURL=ProviderRegistry.d.ts.map
@@ -0,0 +1,36 @@
1
+ export class OAuthProviderRegistry {
2
+ static providers = new Map();
3
+ /**
4
+ * Register an OAuth provider
5
+ */
6
+ static register(provider) {
7
+ if (!provider.providerId) {
8
+ throw new Error(`Provider must have a providerId: ${provider.name}`);
9
+ }
10
+ this.providers.set(provider.providerId, provider);
11
+ }
12
+ /**
13
+ * Get a provider by ID
14
+ */
15
+ static get(providerId) {
16
+ return this.providers.get(providerId);
17
+ }
18
+ /**
19
+ * Get all registered provider IDs
20
+ */
21
+ static getProviderIds() {
22
+ return [...this.providers.keys()];
23
+ }
24
+ /**
25
+ * Check if a provider is registered
26
+ */
27
+ static has(providerId) {
28
+ return this.providers.has(providerId);
29
+ }
30
+ /**
31
+ * Get all providers
32
+ */
33
+ static getAll() {
34
+ return new Map(this.providers);
35
+ }
36
+ }
@@ -0,0 +1,23 @@
1
+ export declare const TokenHelper: {
2
+ /**
3
+ * Get a valid access token for a provider, with user-friendly error messages
4
+ */
5
+ getValidToken(provider: string): Promise<string>;
6
+ /**
7
+ * Get token or use provided access token, with preference for keychain
8
+ */
9
+ getTokenWithFallback(provider: string, providedToken?: string): Promise<string>;
10
+ /**
11
+ * Check if valid credentials exist for a provider
12
+ */
13
+ hasValidCredentials(provider: string): Promise<boolean>;
14
+ /**
15
+ * Remove stored credentials (for logout/reset)
16
+ */
17
+ removeCredentials(provider: string): Promise<void>;
18
+ /**
19
+ * List providers with stored credentials
20
+ */
21
+ listProvidersWithCredentials(): Promise<string[]>;
22
+ };
23
+ //# sourceMappingURL=TokenHelper.d.ts.map
@@ -0,0 +1,56 @@
1
+ import { CredentialManager } from './CredentialManager.js';
2
+ export const TokenHelper = {
3
+ /**
4
+ * Get a valid access token for a provider, with user-friendly error messages
5
+ */
6
+ async getValidToken(provider) {
7
+ const token = await CredentialManager.getValidToken(provider);
8
+ 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
+ }
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`);
17
+ }
18
+ }
19
+ return token;
20
+ },
21
+ /**
22
+ * Get token or use provided access token, with preference for keychain
23
+ */
24
+ async getTokenWithFallback(provider, providedToken) {
25
+ // If user provided a token explicitly, use it
26
+ if (providedToken) {
27
+ return providedToken;
28
+ }
29
+ // Otherwise try to get from keychain
30
+ return this.getValidToken(provider);
31
+ },
32
+ /**
33
+ * Check if valid credentials exist for a provider
34
+ */
35
+ async hasValidCredentials(provider) {
36
+ try {
37
+ const token = await CredentialManager.getValidToken(provider);
38
+ return token !== null;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ },
44
+ /**
45
+ * Remove stored credentials (for logout/reset)
46
+ */
47
+ async removeCredentials(provider) {
48
+ await CredentialManager.removeCredentials(provider);
49
+ },
50
+ /**
51
+ * List providers with stored credentials
52
+ */
53
+ async listProvidersWithCredentials() {
54
+ return CredentialManager.listProviders();
55
+ },
56
+ };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The directory Chronicle keeps its config and credentials in — the same one
3
+ * oclif resolves for `Config.configDir`, so code running outside an oclif
4
+ * command (the agent harness, plugins' own tooling) reads the files that
5
+ * `chronicle config set` and `chronicle auth set` write. `CHRONICLE_CONFIG_DIR`
6
+ * is oclif's scoped override, then XDG_CONFIG_HOME / LOCALAPPDATA (Windows) /
7
+ * ~/.config, mirroring `@oclif/core` `Config.dir('config')`.
8
+ */
9
+ export declare function chronicleConfigDir(): string;
10
+ //# sourceMappingURL=configDir.d.ts.map
@@ -0,0 +1,16 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * The directory Chronicle keeps its config and credentials in — the same one
5
+ * oclif resolves for `Config.configDir`, so code running outside an oclif
6
+ * command (the agent harness, plugins' own tooling) reads the files that
7
+ * `chronicle config set` and `chronicle auth set` write. `CHRONICLE_CONFIG_DIR`
8
+ * is oclif's scoped override, then XDG_CONFIG_HOME / LOCALAPPDATA (Windows) /
9
+ * ~/.config, mirroring `@oclif/core` `Config.dir('config')`.
10
+ */
11
+ export function chronicleConfigDir() {
12
+ return (process.env.CHRONICLE_CONFIG_DIR ||
13
+ join(process.env.XDG_CONFIG_HOME ||
14
+ (process.platform === 'win32' && process.env.LOCALAPPDATA) ||
15
+ join(homedir(), '.config'), 'chronicle'));
16
+ }
@@ -0,0 +1,10 @@
1
+ export * from './CredentialManager.js';
2
+ export { FileCredentialManager } from './FileCredentialManager.js';
3
+ export * from './OAuthProvider.js';
4
+ export * from './ProviderRegistry.js';
5
+ export * from './OAuthServer.js';
6
+ export * from './TokenHelper.js';
7
+ export * from './resolveCredentials.js';
8
+ export * from './types.js';
9
+ export * from './configDir.js';
10
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export * from './CredentialManager.js';
2
+ export { FileCredentialManager } from './FileCredentialManager.js';
3
+ export * from './OAuthProvider.js';
4
+ export * from './ProviderRegistry.js';
5
+ export * from './OAuthServer.js';
6
+ export * from './TokenHelper.js';
7
+ export * from './resolveCredentials.js';
8
+ export * from './types.js';
9
+ export * from './configDir.js';
@@ -0,0 +1,36 @@
1
+ import { StoredCredentials } from './CredentialManager.js';
2
+ /**
3
+ * Shared credential resolution for API plugins. Consolidates the per-plugin
4
+ * pattern of "flag/constructor value, else stored credential under one of a
5
+ * few historical field names, else a source-specific error".
6
+ *
7
+ * This is the CredentialManager (stored-secret) path; OAuth sources that need
8
+ * refresh go through TokenHelper.getValidToken instead.
9
+ */
10
+ export interface CredentialFieldSpec {
11
+ /** StoredCredentials fields tried in order until one has a value. */
12
+ from: Array<keyof StoredCredentials | string>;
13
+ /** Optional fields resolve to undefined instead of failing the lookup. */
14
+ optional?: boolean;
15
+ }
16
+ export interface ResolveCredentialsOptions {
17
+ /**
18
+ * Already-resolved values (CLI flags, constructor args). A non-empty
19
+ * override wins without consulting the store.
20
+ */
21
+ overrides?: Record<string, string | undefined>;
22
+ /**
23
+ * Full replacement for the missing-credential error, so a plugin keeps its
24
+ * exact user-facing message (auth command, credential-creation URL, …).
25
+ */
26
+ errorMessage?: string;
27
+ }
28
+ /** Pure resolution core, separated for testing. */
29
+ export declare function pickCredentialFields<F extends string>(provider: string, stored: object | null, fields: Record<F, CredentialFieldSpec>, options?: ResolveCredentialsOptions): Record<F, string | undefined>;
30
+ /**
31
+ * Resolve a source's credentials: overrides first, then the stored
32
+ * credentials under each field's alias chain. Throws when a required field
33
+ * is missing everywhere.
34
+ */
35
+ export declare function resolveCredentials<F extends string>(provider: string, fields: Record<F, CredentialFieldSpec>, options?: ResolveCredentialsOptions): Promise<Record<F, string | undefined>>;
36
+ //# sourceMappingURL=resolveCredentials.d.ts.map
@@ -0,0 +1,44 @@
1
+ import { CredentialManager } from './CredentialManager.js';
2
+ /** Pure resolution core, separated for testing. */
3
+ export function pickCredentialFields(provider, stored, fields, options = {}) {
4
+ const resolved = {};
5
+ const missing = [];
6
+ const storedFields = (stored ?? {});
7
+ for (const name of Object.keys(fields)) {
8
+ const spec = fields[name];
9
+ const override = options.overrides?.[name];
10
+ let value = override || undefined;
11
+ if (value === undefined && stored) {
12
+ for (const alias of spec.from) {
13
+ const candidate = storedFields[alias];
14
+ if (typeof candidate === 'string' && candidate !== '') {
15
+ value = candidate;
16
+ break;
17
+ }
18
+ }
19
+ }
20
+ resolved[name] = value;
21
+ if (value === undefined && !spec.optional)
22
+ missing.push(name);
23
+ }
24
+ if (missing.length > 0) {
25
+ throw new Error(options.errorMessage ??
26
+ `${provider} credentials are missing ${missing.join(', ')}. Authenticate with: chronicle auth ${provider}`);
27
+ }
28
+ return resolved;
29
+ }
30
+ /**
31
+ * Resolve a source's credentials: overrides first, then the stored
32
+ * credentials under each field's alias chain. Throws when a required field
33
+ * is missing everywhere.
34
+ */
35
+ export async function resolveCredentials(provider, fields, options = {}) {
36
+ let stored = null;
37
+ try {
38
+ stored = await CredentialManager.getCredentials(provider);
39
+ }
40
+ catch {
41
+ // No stored credentials — overrides may still satisfy the spec.
42
+ }
43
+ return pickCredentialFields(provider, stored, fields, options);
44
+ }
@@ -0,0 +1,32 @@
1
+ export interface OAuthParams {
2
+ clientId: string;
3
+ clientSecret: string;
4
+ redirectUri: string;
5
+ scopes?: string[];
6
+ state?: string;
7
+ }
8
+ export interface TokenResponse {
9
+ provider: string;
10
+ access_token: string;
11
+ refresh_token?: string;
12
+ token_type: string;
13
+ expires_in?: number;
14
+ scope?: string;
15
+ created_at: string;
16
+ username?: string;
17
+ }
18
+ export interface OAuthConfig {
19
+ providerId: string;
20
+ authorizationUrl: string;
21
+ tokenUrl: string;
22
+ scopes: string[];
23
+ requiresClientSecret: boolean;
24
+ }
25
+ export interface AuthorizationResult {
26
+ code?: string;
27
+ token?: string;
28
+ state?: string;
29
+ error?: string;
30
+ error_description?: string;
31
+ }
32
+ //# sourceMappingURL=types.d.ts.map
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@chronicle.app/auth",
3
+ "version": "0.1.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [],
17
+ "author": "Andrew Louis",
18
+ "license": "MIT",
19
+ "type": "module",
20
+ "description": "Standalone Chronicle credential and OAuth services",
21
+ "devDependencies": {
22
+ "@chronicle.app/tsconfig": "0.1.0",
23
+ "@types/node": "^22.10.1",
24
+ "typescript": "^5.7.2"
25
+ },
26
+ "homepage": "https://chronicle.app",
27
+ "bugs": {
28
+ "url": "https://github.com/chronicle-app/chronicle/issues/new?labels=core:etl&title=[etl]%20"
29
+ },
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/chronicle-app/chronicle.git",
33
+ "directory": "core/auth"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public",
37
+ "registry": "https://registry.npmjs.org/"
38
+ },
39
+ "dependencies": {
40
+ "axios": "^1.7.9"
41
+ },
42
+ "engines": {
43
+ "node": ">=22.13.0"
44
+ },
45
+ "exports": {
46
+ ".": {
47
+ "types": "./dist/index.d.ts",
48
+ "import": "./dist/index.js"
49
+ }
50
+ }
51
+ }