@modelprofile.com/authswitch-signin 10.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.
@@ -0,0 +1,91 @@
1
+ // OpenAI protocol portions are adapted in TypeScript from OpenAI Codex (Apache-2.0) and modified by Task Venture Capital GmbH; see third-party-notices.md.
2
+
3
+ import {
4
+ OpenAiAuthError, parseOpenAiTokenClaims, refreshOpenAiTokens, type IOpenAiTokenSet,
5
+ } from './signin.protocol.js';
6
+ import type {
7
+ IOpenAiAccountSummary, IOpenAiOAuthCredential, TOpenAiRefreshResult,
8
+ } from './signin.interfaces.js';
9
+
10
+ /** A stored credential that is not a ChatGPT sign-in this package can read. It names no token. */
11
+ export class OpenAiCredentialError extends Error {
12
+ constructor() {
13
+ super('The OpenAI credential is invalid.');
14
+ this.name = 'OpenAiCredentialError';
15
+ }
16
+ }
17
+
18
+ /**
19
+ * The token set a stored credential holds, with its ID-token claims. Throws `OpenAiCredentialError` for anything
20
+ * but a complete ChatGPT sign-in.
21
+ */
22
+ export const openAiTokensOfCredential = (credential: IOpenAiOAuthCredential): IOpenAiTokenSet => {
23
+ if (credential === null || typeof credential !== 'object' || credential.kind !== 'chatgptOAuth'
24
+ || credential.providerId !== 'openai' || typeof credential.accessToken !== 'string' || !credential.accessToken
25
+ || typeof credential.refreshToken !== 'string' || !credential.refreshToken
26
+ || typeof credential.idToken !== 'string' || !credential.idToken) {
27
+ throw new OpenAiCredentialError();
28
+ }
29
+ try {
30
+ return { accessToken: credential.accessToken, refreshToken: credential.refreshToken, idToken: credential.idToken,
31
+ claims: parseOpenAiTokenClaims(credential.idToken) };
32
+ } catch { throw new OpenAiCredentialError(); }
33
+ };
34
+
35
+ /** The credential a sign-in or refresh produced; one without an ID token is refused. */
36
+ export const openAiCredentialOfTokens = (tokens: IOpenAiTokenSet, lastRefreshAt?: string): IOpenAiOAuthCredential => {
37
+ if (!tokens.idToken) throw new OpenAiCredentialError();
38
+ return { kind: 'chatgptOAuth', providerId: 'openai', accessToken: tokens.accessToken,
39
+ refreshToken: tokens.refreshToken, idToken: tokens.idToken,
40
+ ...(lastRefreshAt === undefined ? {} : { lastRefreshAt }) };
41
+ };
42
+
43
+ /** What a credential's claims say about its account; no token. */
44
+ export const summarizeOpenAiCredential = (credential: IOpenAiOAuthCredential): IOpenAiAccountSummary => {
45
+ const claims = openAiTokensOfCredential(credential).claims;
46
+ return {
47
+ providerId: 'openai', authKind: 'chatgptOAuth',
48
+ ...(claims.chatgptAccountId ? { accountId: claims.chatgptAccountId } : {}),
49
+ ...(claims.email ? { email: claims.email } : {}),
50
+ ...(claims.chatgptPlanType ? { plan: claims.chatgptPlanType } : {}),
51
+ };
52
+ };
53
+
54
+ export interface IOpenAiRefreshCredentialOptions {
55
+ fetch?: typeof fetch;
56
+ /** Aborting it after the request was handed to fetch leaves the outcome unknown. */
57
+ signal?: AbortSignal;
58
+ now?: () => Date;
59
+ }
60
+
61
+ /**
62
+ * Sends one refresh-token grant for a credential and answers the rotated credential, or why there is none.
63
+ * A refresh that returned the same tokens, or no ID token, proves nothing about the grant it used and fails as
64
+ * `outcomeUnknown`. `requestOutcome: 'notSent'` is the only failure after which the same refresh token may be
65
+ * sent again: after any other the service may already have rotated it. `lastRefreshAt` rises strictly.
66
+ * Throws `OpenAiCredentialError` for a credential that is not a complete ChatGPT sign-in.
67
+ */
68
+ export const refreshOpenAiCredential = async (credential: IOpenAiOAuthCredential,
69
+ options: IOpenAiRefreshCredentialOptions = {}): Promise<TOpenAiRefreshResult> => {
70
+ const input = openAiTokensOfCredential(credential);
71
+ try {
72
+ const refreshed = await refreshOpenAiTokens(input, { fetch: options.fetch, signal: options.signal });
73
+ if ((refreshed.accessToken === input.accessToken && refreshed.refreshToken === input.refreshToken)
74
+ || !refreshed.idToken) {
75
+ return { success: false, credential, errorCode: 'REFRESH_FAILED', requestOutcome: 'outcomeUnknown' };
76
+ }
77
+ const previous = credential.lastRefreshAt === undefined ? 0 : Date.parse(credential.lastRefreshAt);
78
+ const now = (options.now ?? (() => new Date()))().getTime();
79
+ const lastRefreshAt = new Date(Math.max(now, Number.isFinite(previous) ? previous + 1 : now)).toISOString();
80
+ const next = openAiCredentialOfTokens(refreshed, lastRefreshAt);
81
+ return { success: true, credential: next, account: summarizeOpenAiCredential(next) };
82
+ } catch (error) {
83
+ return {
84
+ success: false, credential,
85
+ errorCode: options.signal?.aborted ? 'ABORTED' : 'REFRESH_FAILED',
86
+ requestOutcome: error instanceof OpenAiAuthError ? error.requestOutcome ?? 'outcomeUnknown' : 'outcomeUnknown',
87
+ ...(error instanceof OpenAiAuthError && error.status !== undefined ? { responseStatus: error.status } : {}),
88
+ ...(error instanceof OpenAiAuthError && error.retryAfterMs !== undefined ? { retryAfterMs: error.retryAfterMs } : {}),
89
+ };
90
+ }
91
+ };
@@ -0,0 +1,137 @@
1
+ // OpenAI protocol portions are adapted in TypeScript from OpenAI Codex (Apache-2.0) and modified by Task Venture Capital GmbH; see third-party-notices.md.
2
+
3
+ import {
4
+ exchangeOpenAiDeviceAuthorization, OPENAI_DEVICE_CODE_LIFETIME_MS, OpenAiAuthError, pollOpenAiDeviceCodeOnce,
5
+ requestOpenAiDeviceCode, type IOpenAiAuthOptions,
6
+ } from './signin.protocol.js';
7
+ import { openAiCredentialOfTokens, summarizeOpenAiCredential } from './signin.credential.js';
8
+ import type { IOpenAiDevicePrompt, IOpenAiLoginResult } from './signin.interfaces.js';
9
+
10
+ const MAX_STATE_STRING_BYTES = 4096;
11
+ const REQUEST_TIMEOUT_MS = 30_000;
12
+
13
+ /**
14
+ * A device sign-in that any process can continue after a restart. It is a secret: its `deviceAuthId` with its
15
+ * `userCode` complete the sign-in, so it is stored sealed, like a credential, and deleted once it is done.
16
+ */
17
+ export interface IOpenAiDeviceSignInState {
18
+ verificationUrl: string;
19
+ userCode: string;
20
+ deviceAuthId: string;
21
+ intervalSeconds: number;
22
+ /** When the device code was issued, ISO 8601. */
23
+ issuedAt: string;
24
+ /** When the device code stops being accepted: fifteen minutes after `issuedAt`, as Codex tells the person. */
25
+ expiresAt: string;
26
+ }
27
+
28
+ /** What the person signing in is shown, with the time the code expires. */
29
+ export interface IOpenAiDeviceSignInPrompt extends IOpenAiDevicePrompt {
30
+ expiresAt: string;
31
+ }
32
+
33
+ export interface IOpenAiDeviceSignInStart {
34
+ prompt: IOpenAiDeviceSignInPrompt;
35
+ state: IOpenAiDeviceSignInState;
36
+ }
37
+
38
+ /**
39
+ * What one poll found:
40
+ * - `pending`: not approved yet; poll again at `nextPollAt` at the earliest.
41
+ * - `expired`: the code is past `expiresAt`; nothing was sent. Start a new sign-in.
42
+ * - `approved`: the person approved it; `result` holds the credential. The state is spent.
43
+ */
44
+ export type TOpenAiDeviceSignInPoll =
45
+ | { status: 'pending'; nextPollAt: string }
46
+ | { status: 'expired' }
47
+ | { status: 'approved'; result: IOpenAiLoginResult };
48
+
49
+ export interface IOpenAiDeviceSignInOptions extends IOpenAiAuthOptions {
50
+ now?: () => Date;
51
+ }
52
+
53
+ const invalidState = (): OpenAiAuthError =>
54
+ new OpenAiAuthError('OpenAI device sign-in state is invalid.', { requestOutcome: 'notSent' });
55
+
56
+ const isBoundedString = (value: unknown): value is string =>
57
+ typeof value === 'string' && value.length > 0 && Buffer.byteLength(value, 'utf8') <= MAX_STATE_STRING_BYTES;
58
+
59
+ const timeOf = (value: unknown): number => {
60
+ if (typeof value !== 'string') return NaN;
61
+ const time = Date.parse(value);
62
+ return Number.isFinite(time) && new Date(time).toISOString() === value ? time : NaN;
63
+ };
64
+
65
+ /**
66
+ * Checks a stored state before anything is sent with it: a malformed one (its shape, or a window longer than the
67
+ * fifteen minutes a code lives) is refused. Only shape and window are checked, not where the state came from.
68
+ */
69
+ const readState = (state: IOpenAiDeviceSignInState): { issuedAt: number; expiresAt: number } => {
70
+ if (state === null || typeof state !== 'object' || !isBoundedString(state.verificationUrl)
71
+ || !isBoundedString(state.userCode) || !isBoundedString(state.deviceAuthId)
72
+ || !Number.isSafeInteger(state.intervalSeconds) || state.intervalSeconds < 1 || state.intervalSeconds > 300) {
73
+ throw invalidState();
74
+ }
75
+ const issuedAt = timeOf(state.issuedAt);
76
+ const expiresAt = timeOf(state.expiresAt);
77
+ if (!Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || expiresAt <= issuedAt
78
+ || expiresAt - issuedAt > OPENAI_DEVICE_CODE_LIFETIME_MS) {
79
+ throw invalidState();
80
+ }
81
+ return { issuedAt, expiresAt };
82
+ };
83
+
84
+ /**
85
+ * Starts a device sign-in and returns what to show the person and the state to keep. The code lives fifteen
86
+ * minutes from the moment it was requested; `issuedAt` is taken before the request, so `expiresAt` never
87
+ * outlives the code.
88
+ */
89
+ export const startOpenAiDeviceSignIn = async (
90
+ options: IOpenAiDeviceSignInOptions = {},
91
+ ): Promise<IOpenAiDeviceSignInStart> => {
92
+ const now = options.now ?? (() => new Date());
93
+ const issuedAt = now().getTime();
94
+ const deviceCode = await requestOpenAiDeviceCode(options);
95
+ const state: IOpenAiDeviceSignInState = {
96
+ verificationUrl: deviceCode.verificationUrl,
97
+ userCode: deviceCode.userCode,
98
+ deviceAuthId: deviceCode.deviceAuthId,
99
+ intervalSeconds: deviceCode.intervalSeconds,
100
+ issuedAt: new Date(issuedAt).toISOString(),
101
+ expiresAt: new Date(issuedAt + OPENAI_DEVICE_CODE_LIFETIME_MS).toISOString(),
102
+ };
103
+ return {
104
+ prompt: { flow: 'device', verificationUrl: state.verificationUrl, userCode: state.userCode, expiresAt: state.expiresAt },
105
+ state,
106
+ };
107
+ };
108
+
109
+ /**
110
+ * Asks once whether the person approved the sign-in, and on approval exchanges it for the credential. A state
111
+ * past `expiresAt` answers `expired` without a request. One request is bounded by the time the code has left
112
+ * and by thirty seconds. The authorization the service answers is single-use: poll one state from one caller
113
+ * at a time, and after `approved`, or after a failure of the exchange, start a new sign-in rather than polling
114
+ * the same state again. Failures are `OpenAiAuthError`s with fixed messages.
115
+ */
116
+ export const pollOpenAiDeviceSignIn = async (
117
+ state: IOpenAiDeviceSignInState,
118
+ options: IOpenAiDeviceSignInOptions = {},
119
+ ): Promise<TOpenAiDeviceSignInPoll> => {
120
+ const { expiresAt } = readState(state);
121
+ const now = options.now ?? (() => new Date());
122
+ const remaining = expiresAt - now().getTime();
123
+ if (remaining <= 0) return { status: 'expired' };
124
+ const poll = await pollOpenAiDeviceCodeOnce({
125
+ verificationUrl: state.verificationUrl, userCode: state.userCode,
126
+ deviceAuthId: state.deviceAuthId, intervalSeconds: state.intervalSeconds,
127
+ }, { fetch: options.fetch, signal: options.signal, timeoutMs: Math.min(remaining, REQUEST_TIMEOUT_MS) });
128
+ if (poll.status === 'pending') {
129
+ const nextPollAt = Math.min(now().getTime() + state.intervalSeconds * 1000, expiresAt);
130
+ return { status: 'pending', nextPollAt: new Date(nextPollAt).toISOString() };
131
+ }
132
+ const tokens = await exchangeOpenAiDeviceAuthorization(poll.authorization, { fetch: options.fetch, signal: options.signal });
133
+ let credential;
134
+ try { credential = openAiCredentialOfTokens(tokens); }
135
+ catch { throw new OpenAiAuthError('OpenAI auth response is missing id_token.', { requestOutcome: 'outcomeUnknown' }); }
136
+ return { status: 'approved', result: { credential, account: summarizeOpenAiCredential(credential) } };
137
+ };