diff --git a/bun.lock b/bun.lock index 6f8c4a4..cc49161 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,8 @@ "": { "name": "listee-cli", "dependencies": { + "@listee/auth": "link:@listee/auth", + "@listee/types": "link:@listee/types", "@napi-rs/keyring": "^1.2.0", "commander": "^12.1.0", "dotenv": "^16.4.5", @@ -35,6 +37,10 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw=="], + "@listee/auth": ["@listee/auth@link:@listee/auth", {}], + + "@listee/types": ["@listee/types@link:@listee/types", {}], + "@napi-rs/keyring": ["@napi-rs/keyring@1.2.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.2.0", "@napi-rs/keyring-darwin-x64": "1.2.0", "@napi-rs/keyring-freebsd-x64": "1.2.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.2.0", "@napi-rs/keyring-linux-arm64-gnu": "1.2.0", "@napi-rs/keyring-linux-arm64-musl": "1.2.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.2.0", "@napi-rs/keyring-linux-x64-gnu": "1.2.0", "@napi-rs/keyring-linux-x64-musl": "1.2.0", "@napi-rs/keyring-win32-arm64-msvc": "1.2.0", "@napi-rs/keyring-win32-ia32-msvc": "1.2.0", "@napi-rs/keyring-win32-x64-msvc": "1.2.0" } }, "sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg=="], "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA=="], diff --git a/package.json b/package.json index 39f622b..25e2ad6 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "test": "bun test" }, "dependencies": { + "@listee/auth": "link:@listee/auth", + "@listee/types": "link:@listee/types", "@napi-rs/keyring": "^1.2.0", "commander": "^12.1.0", "dotenv": "^16.4.5" diff --git a/src/services/auth-service.ts b/src/services/auth-service.ts index d85602c..5337115 100644 --- a/src/services/auth-service.ts +++ b/src/services/auth-service.ts @@ -1,4 +1,9 @@ import { Buffer } from "node:buffer"; +import { + type AccountProvisioner, + createAccountProvisioner, +} from "@listee/auth"; +import type { SupabaseToken } from "@listee/types"; import { AsyncEntry, findCredentials } from "@napi-rs/keyring"; import type { AccessTokenResult, @@ -9,9 +14,15 @@ import type { SupabaseTokenResponse, } from "../types/auth.js"; -export type { AccessTokenResult, AuthStatus, SignupRedirect } from "../types/auth.js"; - const DEFAULT_SERVICE_NAME = "listee-cli"; +let cachedAccountProvisioner: AccountProvisioner | null = null; + +const getAccountProvisioner = (): AccountProvisioner => { + if (cachedAccountProvisioner === null) { + cachedAccountProvisioner = createAccountProvisioner(); + } + return cachedAccountProvisioner; +}; const isRecord = (value: unknown): value is Record => { return typeof value === "object" && value !== null; @@ -197,12 +208,57 @@ const decodeJwtPayload = (token: string): unknown => { } }; -const extractEmailFromAccessToken = (token: string): string => { - const payload = decodeJwtPayload(token); +const isSupabaseTokenPayload = (payload: unknown): payload is SupabaseToken => { if (!isRecord(payload)) { + return false; + } + + const subValue = "sub" in payload ? payload.sub : undefined; + const emailValue = "email" in payload ? payload.email : undefined; + const expValue = "exp" in payload ? payload.exp : undefined; + const iatValue = "iat" in payload ? payload.iat : undefined; + + if ( + !isString(subValue) || + subValue.trim().length === 0 || + !isString(emailValue) || + emailValue.trim().length === 0 || + !isNumber(expValue) || + expValue <= 0 || + !isNumber(iatValue) || + iatValue <= 0 + ) { + return false; + } + + const currentEpochSeconds = Math.floor(Date.now() / 1000); + if (expValue <= currentEpochSeconds) { + return false; + } + + return true; +}; + +const decodeSupabaseToken = (token: string): SupabaseToken => { + const payload = decodeJwtPayload(token); + if (!isSupabaseTokenPayload(payload)) { throw new Error("Access token payload structure is invalid."); } + return payload; +}; + +const extractSubjectFromTokenPayload = (payload: SupabaseToken): string => { + const subjectValue = payload.sub; + if (!isString(subjectValue) || subjectValue.trim().length === 0) { + throw new Error("Access token payload did not include a user id."); + } + + return subjectValue.trim(); +}; + +const extractEmailFromAccessToken = (token: string): string => { + const payload = decodeSupabaseToken(token); const email = payload.email; if (!isString(email) || email.trim().length === 0) { throw new Error("Access token payload did not include an email."); @@ -325,9 +381,10 @@ export const signup = async ( password: string, redirectUrl?: string, ): Promise => { - const path = redirectUrl === undefined - ? "auth/v1/signup" - : `auth/v1/signup?redirect_to=${encodeURIComponent(redirectUrl)}`; + const path = + redirectUrl === undefined + ? "auth/v1/signup" + : `auth/v1/signup?redirect_to=${encodeURIComponent(redirectUrl)}`; const response = await requestSupabase(path, { email, password }); if (!response.ok) { @@ -420,6 +477,29 @@ export const completeSignupFromFragment = async ( fragment: string, ): Promise => { const result = parseSignupFragment(fragment); + await provisionSignupAccount(result); await storeRefreshToken(result.account, result.refreshToken); return result; }; + +const provisionSignupAccount = async ( + result: SignupRedirect, +): Promise => { + const tokenPayload = decodeSupabaseToken(result.accessToken); + const userId = extractSubjectFromTokenPayload(tokenPayload); + const provisioner = getAccountProvisioner(); + + try { + await provisioner.provision({ + userId, + token: tokenPayload, + email: result.account, + }); + } catch (error) { + const message = toErrorMessage(error); + console.error( + `Account provisioning failed for ${result.account}: ${message}`, + ); + throw new Error(`Account provisioning failed: ${message}`); + } +}; diff --git a/tsconfig.json b/tsconfig.json index 3cda614..f4737a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "forceConsistentCasingInFileNames": true, "esModuleInterop": true, "resolveJsonModule": true, + "skipLibCheck": true, "composite": true, "declaration": true, "declarationMap": true,