diff --git a/FEATURE_TODO.md b/FEATURE_TODO.md new file mode 100644 index 00000000..f30360f8 --- /dev/null +++ b/FEATURE_TODO.md @@ -0,0 +1,127 @@ +# Auth Wallet Feature + +Adding NUT-21/22 authentication support (CAT + BAT) to coco-cashu. + +## Background + +BAT (Blind Auth Token) is a standard ecash proof with `unit:'auth', amount:1`. +Most of the existing proof infrastructure can be reused; only OIDC session management is new. +cashu-ts provides `AuthManager` which handles the full CAT/BAT lifecycle internally. + +## Completed + +### 1. AuthSession Model +- [x] `models/AuthSession.ts` — session data structure +- [x] `models/index.ts` export + +### 2. Repository +- [x] `repositories/index.ts` — `AuthSessionRepository` interface +- [x] `RepositoriesBase` — `authSessionRepository` field +- [x] `repositories/memory/MemoryAuthSessionRepository.ts` — in-memory implementation for tests +- [x] `repositories/memory/index.ts` export +- [x] `MemoryRepositories.ts` wiring + +### 3. Errors +- [x] `models/Error.ts` — `AuthSessionError`, `AuthSessionExpiredError` + +### 4. Events +- [x] `events/types.ts` CoreEvents: + - `auth-session:updated` + - `auth-session:deleted` + - `auth-session:expired` + +### 5. Service +- [x] `services/AuthSessionService.ts` — session CRUD + expiration validation +- [x] `services/index.ts` export + +### 6. Tests +- [x] `test/unit/AuthSessionService.test.ts` — unit tests + +### 7. API Integration (AuthApi + Manager + MintAdapter) +- [x] `api/AuthApi.ts` — orchestrates cashu-ts AuthManager per mint + - `startDeviceAuth(mintUrl)` — OIDC Device Code Flow + - `login(mintUrl, tokens)` — manual login with externally obtained tokens + - `restore(mintUrl)` — restore session on app restart + - `logout(mintUrl)` — delete session + disconnect AuthProvider + - `getSession(mintUrl)` / `hasSession(mintUrl)` — session queries + - `getAuthProvider(mintUrl)` — access cashu-ts AuthProvider +- [x] `Manager.ts` — `readonly auth: AuthApi` property +- [x] `MintAdapter.ts` — `setAuthProvider`/`clearAuthProvider` + - Passes `authProvider` to cashu-ts `Mint` constructor +- [x] `api/index.ts` export +- [x] `services/AuthSessionService.ts` — fixed Logger import (`@nestjs/common` → `@core/logging`) +- [x] `test/unit/AuthApi.test.ts` — 9 unit tests +- [x] `test/integration/auth-session.test.ts` — end-to-end via `mgr.auth.startDeviceAuth()` + +## Remaining Work + +### Phase 1: Storage Adapters (Persistence) + +Memory adapter is test-only. Platform adapters needed for real persistence: + +- [ ] `packages/sqlite3/` — Node/CLI +- [ ] `packages/indexeddb/` — Web browser +- [ ] `packages/expo-sqlite/` — React Native +- [ ] `packages/adapter-tests/` — contract tests + +### Phase 2: BAT Persistence (after multi-unit support) + +Persist the cashu-ts AuthManager BAT pool across app restarts: + +- [ ] `AuthManager.exportPool()` → save to repository +- [ ] `AuthManager.importPool()` → restore on startup +- [ ] Parameterize WalletService unit (`unit:'auth'` keyset support) +- [ ] Wire authProvider into `WalletService.buildWallet()` Mint/Wallet instances + +### Phase 3: React Wrapper (optional) + +- [ ] `packages/react/` auth hooks + - `useAuthSession()` + - `useBatPool()` + +## Architecture (Current) + +``` +mgr.auth.startDeviceAuth(mintUrl) + → Creates AuthManager + OIDCAuth (cashu-ts) + → oidc.startDeviceAuth() → user authorizes → poll() + → AuthSessionService.saveSession() (persistence) + → MintAdapter.setAuthProvider() (injects authProvider into Mint) + → All subsequent Mint requests auto-include CAT/BAT headers + +AuthManager (built into cashu-ts): + - CAT storage / retrieval / auto-refresh via OIDCAuth + - BAT auto-minting / pool management / DLEQ validation + - Auto-detects NUT-21 (CAT) vs NUT-22 (BAT) per endpoint +``` + +## Reference Patterns + +| New | Existing Pattern | +|-----|-----------------| +| AuthSessionRepository | MintQuoteRepository | +| AuthSessionService | MintQuoteService | + +## Conventions + +- **Normalize mint URLs**: always pass through `normalizeMintUrl()` before storage/comparison +- **Emit events**: emit EventBus events on every state change +- **Domain errors**: use `models/Error.ts` classes instead of plain `new Error()` +- **Include cause**: preserve original error when wrapping + +## Running Tests + +```sh +# All core tests +bun run --filter='coco-cashu-core' test + +# AuthSessionService only +cd packages/core +bun test test/unit/AuthSessionService.test.ts + +# AuthApi only +bun test test/unit/AuthApi.test.ts + +# Integration test (requires running mint with NUT-21/22 + manual OIDC authorization) +MINT_URL=http://localhost:8085 bun test test/integration/auth-session.test.ts --timeout 300000 +``` diff --git a/bun.lock b/bun.lock index 2a9c0144..32fe5335 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "coco-cashu", @@ -13,7 +12,7 @@ }, "packages/adapter-tests": { "name": "coco-cashu-adapter-tests", - "version": "1.1.2-rc.42", + "version": "1.1.2-rc.43", "dependencies": { "fake-bolt11": "^0.1.0", }, @@ -23,13 +22,13 @@ }, "peerDependencies": { "@cashu/cashu-ts": "^3.3.0", - "coco-cashu-core": "^1.1.2-rc.42", + "coco-cashu-core": "^1.1.2-rc.43", "typescript": "^5", }, }, "packages/core": { "name": "coco-cashu-core", - "version": "1.1.2-rc.42", + "version": "1.1.2-rc.43", "dependencies": { "@cashu/cashu-ts": "3.3.0", "@noble/curves": "^2.0.1", @@ -38,7 +37,7 @@ }, "devDependencies": { "@types/bun": "latest", - "coco-cashu-adapter-tests": "1.1.2-rc.42", + "coco-cashu-adapter-tests": "1.1.2-rc.43", "esbuild": "latest", "tsdown": "0.20.1", "typescript": "^5.9.2", @@ -60,7 +59,7 @@ }, "packages/expo-sqlite": { "name": "coco-cashu-expo-sqlite", - "version": "1.1.2-rc.42", + "version": "1.1.2-rc.43", "devDependencies": { "coco-cashu-adapter-tests": "1.1.2-rc.42", "tsdown": "0.20.1", @@ -73,7 +72,7 @@ }, "packages/indexeddb": { "name": "coco-cashu-indexeddb", - "version": "1.1.2-rc.42", + "version": "1.1.2-rc.43", "dependencies": { "dexie": "^4.0.8", }, @@ -92,14 +91,14 @@ }, "packages/react": { "name": "coco-cashu-react", - "version": "1.1.2-rc.42", + "version": "1.1.2-rc.43", "devDependencies": { "@eslint/js": "^9.33.0", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", "ajv": "^8.17.1", - "coco-cashu-core": "1.1.2-rc.42", + "coco-cashu-core": "1.1.2-rc.43", "eslint": "^9.33.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", @@ -111,13 +110,13 @@ "vite-plugin-dts": "^4.5.4", }, "peerDependencies": { - "coco-cashu-core": "1.1.2-rc.42", + "coco-cashu-core": "1.1.2-rc.43", "react": "^19", }, }, "packages/sqlite3": { "name": "coco-cashu-sqlite3", - "version": "1.1.2-rc.42", + "version": "1.1.2-rc.43", "devDependencies": { "@types/sqlite3": "^3.1.8", "coco-cashu-adapter-tests": "1.1.2-rc.42", @@ -411,57 +410,57 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -1217,7 +1216,7 @@ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], - "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -2585,6 +2584,8 @@ "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "vitest/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -2825,6 +2826,58 @@ "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@babel/highlight/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "@babel/highlight/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], diff --git a/packages/core/Manager.ts b/packages/core/Manager.ts index 2b85d932..25c67ea4 100644 --- a/packages/core/Manager.ts +++ b/packages/core/Manager.ts @@ -20,6 +20,7 @@ import { KeyRingService, TransactionService, PaymentRequestService, + AuthSessionService, } from './services'; import { SendOperationService } from './operations/send/SendOperationService'; import { MeltOperationService } from './operations/melt/MeltOperationService'; @@ -34,7 +35,7 @@ import { } from './infra'; import { EventBus, type CoreEvents } from './events'; import { type Logger, NullLogger } from './logging'; -import { MintApi, WalletApi, QuotesApi, HistoryApi, KeyRingApi, SendApi } from './api'; +import { MintApi, WalletApi, QuotesApi, HistoryApi, KeyRingApi, SendApi, AuthApi } from './api'; import { SubscriptionApi } from './api/SubscriptionApi.ts'; import { PluginHost } from './plugins/PluginHost.ts'; import type { Plugin, ServiceMap, PluginExtensions } from './plugins/types.ts'; @@ -164,6 +165,7 @@ export class Manager { readonly subscription: SubscriptionApi; readonly history: HistoryApi; readonly send: SendApi; + readonly auth: AuthApi; readonly ext: PluginExtensions; private mintService: MintService; private walletService: WalletService; @@ -184,6 +186,7 @@ export class Manager { private counterService: CounterService; private transactionService: TransactionService; private paymentRequestService: PaymentRequestService; + private authSessionService: AuthSessionService; private sendOperationService: SendOperationService; private sendOperationRepository: SendOperationRepository; private meltOperationService: MeltOperationService; @@ -241,6 +244,7 @@ export class Manager { this.sendOperationRepository = core.sendOperationRepository; this.meltOperationService = core.meltOperationService; this.meltOperationRepository = core.meltOperationRepository; + this.authSessionService = core.authSessionService; this.proofRepository = repositories.proofRepository; const apis = this.buildApis(); this.mint = apis.mint; @@ -250,6 +254,7 @@ export class Manager { this.subscription = apis.subscription; this.history = apis.history; this.send = apis.send; + this.auth = apis.auth; // Point ext to pluginHost's extensions storage this.ext = this.pluginHost.getExtensions() as PluginExtensions; @@ -549,6 +554,7 @@ export class Manager { sendOperationRepository: SendOperationRepository; meltOperationService: MeltOperationService; meltOperationRepository: MeltOperationRepository; + authSessionService: AuthSessionService; } { const mintLogger = this.getChildLogger('MintService'); const walletLogger = this.getChildLogger('WalletService'); @@ -672,6 +678,13 @@ export class Manager { paymentRequestLogger, ); + const authSessionLogger = this.getChildLogger('AuthSessionService'); + const authSessionService = new AuthSessionService( + repositories.authSessionRepository, + authSessionLogger, + this.eventBus, + ); + return { mintService, seedService, @@ -690,6 +703,7 @@ export class Manager { sendOperationRepository, meltOperationService, meltOperationRepository, + authSessionService, }; } @@ -701,6 +715,7 @@ export class Manager { subscription: SubscriptionApi; history: HistoryApi; send: SendApi; + auth: AuthApi; } { const walletApiLogger = this.getChildLogger('WalletApi'); const subscriptionApiLogger = this.getChildLogger('SubscriptionApi'); @@ -724,6 +739,8 @@ export class Manager { const subscription = new SubscriptionApi(this.subscriptions, subscriptionApiLogger); const history = new HistoryApi(this.historyService); const send = new SendApi(this.sendOperationService); - return { mint, wallet, quotes, keyring, subscription, history, send }; + const authApiLogger = this.getChildLogger('AuthApi'); + const auth = new AuthApi(this.authSessionService, this.mintAdapter, authApiLogger); + return { mint, wallet, quotes, keyring, subscription, history, send, auth }; } } diff --git a/packages/core/api/AuthApi.ts b/packages/core/api/AuthApi.ts new file mode 100644 index 00000000..4b6364dd --- /dev/null +++ b/packages/core/api/AuthApi.ts @@ -0,0 +1,228 @@ +import { + AuthManager, + Mint, + type OIDCAuth, + type AuthProvider, + type TokenResponse, +} from '@cashu/cashu-ts'; +import type { AuthSessionService } from '@core/services'; +import type { AuthSession } from '@core/models'; +import type { MintAdapter } from '@core/infra/MintAdapter'; +import type { Logger } from '@core/logging'; +import { normalizeMintUrl } from '@core/utils'; + +/** + * Public API for NUT-21/22 authentication. + * + * Orchestrates cashu-ts AuthManager (CAT/BAT lifecycle) and + * AuthSessionService (token persistence) so callers only need + * `mgr.auth.*` to authenticate with mints. + */ +export class AuthApi { + /** Per-mint AuthManager (always present after login/restore). */ + private readonly managers = new Map(); + /** Per-mint OIDCAuth (present when refresh_token is available). */ + private readonly oidcClients = new Map(); + + constructor( + private readonly authSessionService: AuthSessionService, + private readonly mintAdapter: MintAdapter, + private readonly logger?: Logger, + ) {} + + // --------------------------------------------------------------------------- + // OIDC Device Code flow + // --------------------------------------------------------------------------- + + /** + * Start an OIDC Device Code authorization flow for a mint. + * + * Returns the device-code fields (verification_uri, user_code, etc.) + * plus a `poll()` helper that resolves once the user authorizes. + * After `poll()` succeeds the session is persisted and the + * AuthProvider is wired into MintAdapter automatically. + */ + async startDeviceAuth(mintUrl: string) { + mintUrl = normalizeMintUrl(mintUrl); + + const auth = new AuthManager(mintUrl); + const mint = new Mint(mintUrl, { authProvider: auth }); + const oidc = await mint.oidcAuth({ + onTokens: (t: TokenResponse) => { + auth.setCAT(t.access_token); + if (t.access_token) { + void this.authSessionService.saveSession(mintUrl, { + access_token: t.access_token, + refresh_token: t.refresh_token, + expires_in: t.expires_in, + }); + } + }, + }); + auth.attachOIDC(oidc); + + const device = await oidc.startDeviceAuth(); + + return { + verification_uri: device.verification_uri, + verification_uri_complete: device.verification_uri_complete, + user_code: device.user_code, + /** Poll until the user authorizes; resolves with the OIDC tokens. */ + poll: async (): Promise => { + const tokens = await device.poll(); + await this.authSessionService.saveSession(mintUrl, { + access_token: tokens.access_token!, + refresh_token: tokens.refresh_token, + expires_in: tokens.expires_in, + }); + this.managers.set(mintUrl, auth); + this.oidcClients.set(mintUrl, oidc); + this.mintAdapter.setAuthProvider(mintUrl, auth); + this.logger?.info('Auth session established', { mintUrl }); + return tokens; + }, + /** Cancel the pending device-code poll. */ + cancel: device.cancel, + }; + } + + // --------------------------------------------------------------------------- + // Manual login (caller already has tokens, e.g. from auth-code flow) + // --------------------------------------------------------------------------- + + /** + * Save OIDC tokens as an auth session and wire the AuthProvider. + * + * Use this when the caller already obtained tokens externally + * (e.g. via Authorization Code + PKCE or password grant). + */ + async login( + mintUrl: string, + tokens: { + access_token: string; + refresh_token?: string; + expires_in?: number; + scope?: string; + }, + ): Promise { + mintUrl = normalizeMintUrl(mintUrl); + const session = await this.authSessionService.saveSession(mintUrl, tokens); + + const auth = new AuthManager(mintUrl); + auth.setCAT(tokens.access_token); + + if (tokens.refresh_token) { + await this.attachOIDC(mintUrl, auth); + } + + this.managers.set(mintUrl, auth); + this.mintAdapter.setAuthProvider(mintUrl, auth); + this.logger?.info('Auth login completed', { mintUrl }); + return session; + } + + // --------------------------------------------------------------------------- + // Restore (app restart) + // --------------------------------------------------------------------------- + + /** + * Restore a persisted auth session and wire the AuthProvider. + * + * Call this on app startup for each mint that has a stored session. + * Returns true if a valid session was found and restored. + */ + async restore(mintUrl: string): Promise { + mintUrl = normalizeMintUrl(mintUrl); + + let session: AuthSession; + try { + session = await this.authSessionService.getValidSession(mintUrl); + } catch { + return false; + } + + const auth = new AuthManager(mintUrl); + auth.setCAT(session.accessToken); + + if (session.refreshToken) { + try { + await this.attachOIDC(mintUrl, auth); + } catch (err) { + this.logger?.warn('Failed to attach OIDC for refresh during restore', { + mintUrl, + cause: err instanceof Error ? err.message : String(err), + }); + } + } + + this.managers.set(mintUrl, auth); + this.mintAdapter.setAuthProvider(mintUrl, auth); + this.logger?.info('Auth session restored', { mintUrl }); + return true; + } + + // --------------------------------------------------------------------------- + // Logout + // --------------------------------------------------------------------------- + + /** Delete the auth session and disconnect the AuthProvider. */ + async logout(mintUrl: string): Promise { + mintUrl = normalizeMintUrl(mintUrl); + await this.authSessionService.deleteSession(mintUrl); + this.managers.delete(mintUrl); + this.oidcClients.delete(mintUrl); + this.mintAdapter.clearAuthProvider(mintUrl); + this.logger?.info('Auth logout completed', { mintUrl }); + } + + // --------------------------------------------------------------------------- + // Session queries + // --------------------------------------------------------------------------- + + /** Get a valid (non-expired) session; throws if missing or expired. */ + async getSession(mintUrl: string): Promise { + return this.authSessionService.getValidSession(mintUrl); + } + + /** Check whether a session exists for the given mint. */ + async hasSession(mintUrl: string): Promise { + return this.authSessionService.hasSession(mintUrl); + } + + // --------------------------------------------------------------------------- + // AuthProvider access (for advanced use) + // --------------------------------------------------------------------------- + + /** Get the AuthProvider for a mint, or undefined if not authenticated. */ + getAuthProvider(mintUrl: string): AuthProvider | undefined { + mintUrl = normalizeMintUrl(mintUrl); + return this.managers.get(mintUrl); + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /** + * Create an OIDCAuth instance from the mint's NUT-21 metadata, + * attach it to the AuthManager for automatic CAT refresh, and + * register the onTokens callback for persistence. + */ + private async attachOIDC(mintUrl: string, auth: AuthManager): Promise { + const mint = new Mint(mintUrl, { authProvider: auth }); + const oidc = await mint.oidcAuth({ + onTokens: (t: TokenResponse) => { + auth.setCAT(t.access_token); + if (t.access_token) { + void this.authSessionService.saveSession(mintUrl, { + access_token: t.access_token, + refresh_token: t.refresh_token, + expires_in: t.expires_in, + }); + } + }, + }); + auth.attachOIDC(oidc); + this.oidcClients.set(mintUrl, oidc); + } +} diff --git a/packages/core/api/index.ts b/packages/core/api/index.ts index 1914bd43..47dc4cfa 100644 --- a/packages/core/api/index.ts +++ b/packages/core/api/index.ts @@ -5,3 +5,4 @@ export * from './KeyRingApi.ts'; export * from './SubscriptionApi.ts'; export * from './HistoryApi.ts'; export * from './SendApi.ts'; +export * from './AuthApi.ts'; diff --git a/packages/core/events/types.ts b/packages/core/events/types.ts index f7b912c6..0d9e3c8a 100644 --- a/packages/core/events/types.ts +++ b/packages/core/events/types.ts @@ -1,6 +1,3 @@ -import type { Mint } from '../models/Mint'; -import type { Keyset } from '../models/Keyset'; -import type { Counter } from '../models/Counter'; import type { MeltQuoteBolt11Response, MeltQuoteState, @@ -8,10 +5,13 @@ import type { MintQuoteState, Token, } from '@cashu/cashu-ts'; -import type { CoreProof, ProofState } from '../types'; +import type { MeltOperation } from '@core/operations/melt'; +import type { Counter } from '../models/Counter'; import type { HistoryEntry } from '../models/History'; +import type { Keyset } from '../models/Keyset'; +import type { Mint } from '../models/Mint'; import type { SendOperation } from '../operations/send/SendOperation'; -import type { MeltOperation } from '@core/operations/melt'; +import type { CoreProof, ProofState } from '../types'; export interface CoreEvents { 'mint:added': { mint: Mint; keysets: Keyset[] }; @@ -59,4 +59,7 @@ export interface CoreEvents { 'melt-op:rolled-back': { mintUrl: string; operationId: string; operation: MeltOperation }; 'subscriptions:paused': void; 'subscriptions:resumed': void; + 'auth-session:updated': {mintUrl:string}; + 'auth-session:deleted': {mintUrl:string}; + 'auth-session:expired': {mintUrl: string}; } diff --git a/packages/core/infra/MintAdapter.ts b/packages/core/infra/MintAdapter.ts index f66a8865..627e3ce0 100644 --- a/packages/core/infra/MintAdapter.ts +++ b/packages/core/infra/MintAdapter.ts @@ -6,6 +6,7 @@ import { type MeltQuoteBolt11Response, type MeltQuoteBolt12Response, type GetKeysetsResponse, + type AuthProvider, } from '@cashu/cashu-ts'; import type { MintInfo } from '../types'; import type { MintRequestProvider } from './MintRequestProvider.ts'; @@ -19,11 +20,24 @@ import type { MintRequestProvider } from './MintRequestProvider.ts'; export class MintAdapter { private cashuMints: Record = {}; private readonly requestProvider: MintRequestProvider; + private readonly authProviders = new Map(); constructor(requestProvider: MintRequestProvider) { this.requestProvider = requestProvider; } + /** Register an AuthProvider for a mint (NUT-21/22). Invalidates the cached Mint instance. */ + setAuthProvider(mintUrl: string, provider: AuthProvider): void { + this.authProviders.set(mintUrl, provider); + delete this.cashuMints[mintUrl]; + } + + /** Remove the AuthProvider for a mint. Invalidates the cached Mint instance. */ + clearAuthProvider(mintUrl: string): void { + this.authProviders.delete(mintUrl); + delete this.cashuMints[mintUrl]; + } + async fetchMintInfo(mintUrl: string): Promise { const cashuMint = this.getCashuMint(mintUrl); return await cashuMint.getInfo(); @@ -46,7 +60,8 @@ export class MintAdapter { private getCashuMint(mintUrl: string): Mint { if (!this.cashuMints[mintUrl]) { const requestFn = this.requestProvider.getRequestFn(mintUrl); - this.cashuMints[mintUrl] = new Mint(mintUrl, { customRequest: requestFn }); + const authProvider = this.authProviders.get(mintUrl); + this.cashuMints[mintUrl] = new Mint(mintUrl, { customRequest: requestFn, authProvider }); } return this.cashuMints[mintUrl]; } diff --git a/packages/core/models/AuthSession.ts b/packages/core/models/AuthSession.ts new file mode 100644 index 00000000..76121c20 --- /dev/null +++ b/packages/core/models/AuthSession.ts @@ -0,0 +1,7 @@ +export interface AuthSession { + mintUrl: string; + accessToken: string; + refreshToken?: string; + expiresAt: number; + scope?: string; +} \ No newline at end of file diff --git a/packages/core/models/Error.ts b/packages/core/models/Error.ts index 7b50fa5b..0bb4adc1 100644 --- a/packages/core/models/Error.ts +++ b/packages/core/models/Error.ts @@ -115,3 +115,20 @@ export class OperationInProgressError extends Error { this.operationId = operationId; } } + +export class AuthSessionError extends Error { + readonly mintUrl: string; + constructor(mintUrl:string, message?: string, cause?: unknown) { + super(message ?? `Auth session error for mint ${mintUrl}`); + this.name = 'AuthSessionError'; + this.mintUrl = mintUrl; + (this as unknown as {cause?: unknown}).cause = cause; + } +} + +export class AuthSessionExpiredError extends AuthSessionError{ + constructor(mintUrl: string) { + super(mintUrl, `Auth session expired for mint ${mintUrl}`); + this.name = `AuthSessionExpiredError`; + } +} diff --git a/packages/core/models/index.ts b/packages/core/models/index.ts index f2f2d1c3..cb6066e4 100644 --- a/packages/core/models/index.ts +++ b/packages/core/models/index.ts @@ -1,8 +1,9 @@ +export * from './AuthSession'; export * from './Counter'; export * from './Error'; +export * from './History'; export * from './Keypair'; export * from './Keyset'; +export * from './MeltQuote'; export * from './Mint'; export * from './MintQuote'; -export * from './MeltQuote'; -export * from './History'; diff --git a/packages/core/repositories/index.ts b/packages/core/repositories/index.ts index 7444724c..b5783226 100644 --- a/packages/core/repositories/index.ts +++ b/packages/core/repositories/index.ts @@ -1,9 +1,4 @@ -import type { Mint } from '../models/Mint'; -import type { Keyset } from '../models/Keyset'; -import type { Counter } from '../models/Counter'; -import type { CoreProof, ProofState } from '../types'; -import type { MintQuote } from '@core/models/MintQuote'; -import type { MeltQuote } from '@core/models/MeltQuote'; +import type { AuthSession } from '@core/models/AuthSession'; import type { HistoryEntry, MeltHistoryEntry, @@ -11,11 +6,15 @@ import type { SendHistoryEntry, SendHistoryState, } from '@core/models/History'; -import type { MeltQuoteState, MintQuoteState } from '@cashu/cashu-ts'; import type { Keypair } from '@core/models/Keypair'; -import type { SendOperation, SendOperationState } from '../operations/send/SendOperation'; +import type { MeltQuote } from '@core/models/MeltQuote'; +import type { MintQuote } from '@core/models/MintQuote'; import type { MeltOperation, MeltOperationState } from '@core/operations/melt/MeltOperation'; - +import type { Counter } from '../models/Counter'; +import type { Keyset } from '../models/Keyset'; +import type { Mint } from '../models/Mint'; +import type { SendOperation, SendOperationState } from '../operations/send/SendOperation'; +import type { CoreProof, ProofState } from '../types'; export interface MintRepository { isTrustedMint(mintUrl: string): Promise; getMintByUrl(mintUrl: string): Promise; @@ -180,7 +179,15 @@ export interface MeltOperationRepository { /** Delete a melt operation */ delete(id: string): Promise; } +export interface AuthSessionRepository{ + getSession(mintUrl: string): Promise; + + saveSession(session:AuthSession): Promise; + deleteSession(mintUrl: string): Promise; + + getAllSessions(): Promise; +} interface RepositoriesBase { mintRepository: MintRepository; keyRingRepository: KeyRingRepository; @@ -192,6 +199,7 @@ interface RepositoriesBase { historyRepository: HistoryRepository; sendOperationRepository: SendOperationRepository; meltOperationRepository: MeltOperationRepository; + authSessionRepository: AuthSessionRepository; } export interface Repositories extends RepositoriesBase { diff --git a/packages/core/repositories/memory/MemoryAuthSessionRepository.ts b/packages/core/repositories/memory/MemoryAuthSessionRepository.ts new file mode 100644 index 00000000..20061f12 --- /dev/null +++ b/packages/core/repositories/memory/MemoryAuthSessionRepository.ts @@ -0,0 +1,23 @@ +import type { AuthSession } from '@core/models/AuthSession'; +import type { AuthSessionRepository } from '..'; + +export class MemoryAuthSessionRepository implements AuthSessionRepository{ + + private readonly sessions = new Map(); + + async getSession(mintUrl: string): Promise{ + return this.sessions.get(mintUrl) ?? null; + } + + async saveSession(session: AuthSession): Promise { + this.sessions.set(session.mintUrl, session); + } + + async deleteSession(mintUrl: string): Promise{ + this.sessions.delete(mintUrl); + } + + async getAllSessions(): Promise { + return [...this.sessions.values()]; + } +} \ No newline at end of file diff --git a/packages/core/repositories/memory/MemoryRepositories.ts b/packages/core/repositories/memory/MemoryRepositories.ts index f6092b36..abe250a9 100644 --- a/packages/core/repositories/memory/MemoryRepositories.ts +++ b/packages/core/repositories/memory/MemoryRepositories.ts @@ -1,27 +1,29 @@ import type { - Repositories, - RepositoryTransactionScope, - MintRepository, - KeysetRepository, - KeyRingRepository, + AuthSessionRepository, CounterRepository, - ProofRepository, - MintQuoteRepository, - MeltQuoteRepository, HistoryRepository, - SendOperationRepository, + KeyRingRepository, + KeysetRepository, MeltOperationRepository, + MeltQuoteRepository, + MintQuoteRepository, + MintRepository, + ProofRepository, + Repositories, + RepositoryTransactionScope, + SendOperationRepository, } from '..'; -import { MemoryMintRepository } from './MemoryMintRepository'; -import { MemoryKeysetRepository } from './MemoryKeysetRepository'; -import { MemoryKeyRingRepository } from './MemoryKeyRingRepository'; +import { MemoryAuthSessionRepository } from './MemoryAuthSessionRepository'; import { MemoryCounterRepository } from './MemoryCounterRepository'; -import { MemoryProofRepository } from './MemoryProofRepository'; -import { MemoryMintQuoteRepository } from './MemoryMintQuoteRepository'; -import { MemoryMeltQuoteRepository } from './MemoryMeltQuoteRepository'; import { MemoryHistoryRepository } from './MemoryHistoryRepository'; -import { MemorySendOperationRepository } from './MemorySendOperationRepository'; +import { MemoryKeyRingRepository } from './MemoryKeyRingRepository'; +import { MemoryKeysetRepository } from './MemoryKeysetRepository'; import { MemoryMeltOperationRepository } from './MemoryMeltOperationRepository'; +import { MemoryMeltQuoteRepository } from './MemoryMeltQuoteRepository'; +import { MemoryMintQuoteRepository } from './MemoryMintQuoteRepository'; +import { MemoryMintRepository } from './MemoryMintRepository'; +import { MemoryProofRepository } from './MemoryProofRepository'; +import { MemorySendOperationRepository } from './MemorySendOperationRepository'; export class MemoryRepositories implements Repositories { mintRepository: MintRepository; @@ -34,6 +36,7 @@ export class MemoryRepositories implements Repositories { historyRepository: HistoryRepository; sendOperationRepository: SendOperationRepository; meltOperationRepository: MeltOperationRepository; + authSessionRepository: AuthSessionRepository; constructor() { this.mintRepository = new MemoryMintRepository(); @@ -46,6 +49,7 @@ export class MemoryRepositories implements Repositories { this.historyRepository = new MemoryHistoryRepository(); this.sendOperationRepository = new MemorySendOperationRepository(); this.meltOperationRepository = new MemoryMeltOperationRepository(); + this.authSessionRepository = new MemoryAuthSessionRepository(); } async init(): Promise { diff --git a/packages/core/repositories/memory/index.ts b/packages/core/repositories/memory/index.ts index 6f232f7f..781e599d 100644 --- a/packages/core/repositories/memory/index.ts +++ b/packages/core/repositories/memory/index.ts @@ -1,11 +1,13 @@ +export * from './MemoryAuthSessionRepository'; export * from './MemoryCounterRepository'; -export * from './MemoryKeysetRepository'; +export * from './MemoryHistoryRepository'; export * from './MemoryKeyRingRepository'; +export * from './MemoryKeysetRepository'; +export * from './MemoryMeltOperationRepository'; +export * from './MemoryMeltQuoteRepository'; +export * from './MemoryMintQuoteRepository'; export * from './MemoryMintRepository'; export * from './MemoryProofRepository'; export * from './MemoryRepositories'; -export * from './MemoryMintQuoteRepository'; -export * from './MemoryMeltQuoteRepository'; -export * from './MemoryHistoryRepository'; export * from './MemorySendOperationRepository'; -export * from './MemoryMeltOperationRepository'; + diff --git a/packages/core/services/AuthSessionService.ts b/packages/core/services/AuthSessionService.ts new file mode 100644 index 00000000..66708075 --- /dev/null +++ b/packages/core/services/AuthSessionService.ts @@ -0,0 +1,75 @@ +import type { CoreEvents, EventBus } from "@core/events"; +import { AuthSessionError, AuthSessionExpiredError } from "@core/models"; +import type { AuthSessionRepository } from "@core/repositories"; +import { normalizeMintUrl } from "@core/utils"; +import type { Logger } from "@core/logging"; +import type { AuthSession } from '../models/AuthSession'; + + + +export class AuthSessionService { + private readonly repo: AuthSessionRepository; + private readonly eventBus?: EventBus; + private readonly logger?: Logger; + + constructor( + repo: AuthSessionRepository, + logger?: Logger, + eventBus?: EventBus + ) { + this.repo = repo; + this.eventBus = eventBus; + this.logger = logger; + } + + /** Get Valid Session, Error if it's expired*/ + async getValidSession(mintUrl: string): Promise { + mintUrl = normalizeMintUrl(mintUrl); + const session = await this.repo.getSession(mintUrl); + if(!session){ + throw new AuthSessionError(mintUrl, 'No auth session found'); + } + const now = Math.floor(Date.now() / 1000); + if(session.expiresAt <= now){ + await this.eventBus?.emit('auth-session:expired', {mintUrl}); + throw new AuthSessionExpiredError(mintUrl); + } + return session; + } + + +/** Get OIDC token and save session */ + async saveSession( + mintUrl: string, + tokens: { access_token: string; refresh_token?: string; expires_in?: number; scope?: string }, + ): Promise { + mintUrl = normalizeMintUrl(mintUrl); + const now = Math.floor(Date.now() / 1000); + const session: AuthSession = { + mintUrl, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: now + (tokens.expires_in ?? 3600), + scope: tokens.scope, + }; + await this.repo.saveSession(session); + await this.eventBus?.emit('auth-session:updated', { mintUrl }); + this.logger?.info('Auth session saved', { mintUrl, expiresAt: session.expiresAt }); + return session; + } + + /** logout */ + async deleteSession(mintUrl: string): Promise { + mintUrl = normalizeMintUrl(mintUrl); + await this.repo.deleteSession(mintUrl); + await this.eventBus?.emit('auth-session:deleted', { mintUrl }); + this.logger?.info('Auth session deleted', { mintUrl }); + } + + /**has session */ + async hasSession(mintUrl: string): Promise { + mintUrl = normalizeMintUrl(mintUrl); + const session = await this.repo.getSession(mintUrl); + return session !== null; + } + } diff --git a/packages/core/services/index.ts b/packages/core/services/index.ts index 7743a99a..92f45bec 100644 --- a/packages/core/services/index.ts +++ b/packages/core/services/index.ts @@ -1,13 +1,14 @@ +export * from './AuthSessionService'; export * from './CounterService'; +export * from './HistoryService'; export * from './KeyRingService'; +export * from './MeltQuoteService'; +export * from './MintQuoteService'; export * from './MintService'; -export * from './WalletService'; +export * from './PaymentRequestService'; export * from './ProofService'; -export * from './MintQuoteService'; -export * from './watchers'; export * from './SeedService'; -export * from './WalletRestoreService'; -export * from './MeltQuoteService'; -export * from './HistoryService'; export * from './TransactionService'; -export * from './PaymentRequestService'; +export * from './WalletRestoreService'; +export * from './WalletService'; +export * from './watchers'; diff --git a/packages/core/test/integration/auth-session.test.ts b/packages/core/test/integration/auth-session.test.ts new file mode 100644 index 00000000..2446eb2d --- /dev/null +++ b/packages/core/test/integration/auth-session.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'bun:test'; +import { initializeCoco } from '../../Manager'; +import { MemoryRepositories } from '../../repositories/memory'; + +const mintUrl = process.env.MINT_URL; + +if (!mintUrl) { + throw new Error('MINT_URL is not set'); +} + +describe('Auth Session (Device Code Flow)', () => { + it('should authenticate via OIDC device code and wire AuthProvider', async () => { + const repositories = new MemoryRepositories(); + await repositories.init(); + + const mgr = await initializeCoco({ + repo: repositories, + seedGetter: async () => new Uint8Array(32), + watchers: { + mintQuoteWatcher: { disabled: true }, + proofStateWatcher: { disabled: true }, + }, + processors: { + mintQuoteProcessor: { disabled: true }, + }, + }); + + // Start Device Code Flow via mgr.auth + const device = await mgr.auth.startDeviceAuth(mintUrl); + + // Display authorization instructions + console.log('\n========================================'); + console.log(' OIDC Device Code Authorization'); + console.log('========================================'); + console.log(` Visit: ${device.verification_uri_complete || device.verification_uri}`); + console.log(` Code: ${device.user_code}`); + console.log(' Waiting for authorization...'); + console.log('========================================\n'); + + // Wait for user to authorize in browser + const tokens = await device.poll(); + + expect(tokens.access_token).toBeDefined(); + console.log('Authorization successful - access_token received'); + + // Verify session was persisted + const session = await mgr.auth.getSession(mintUrl); + expect(session.accessToken).toBe(tokens.access_token!); + expect(session.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000)); + + // Verify AuthProvider is wired + const provider = mgr.auth.getAuthProvider(mintUrl); + expect(provider).toBeDefined(); + expect(provider!.getCAT()).toBe(tokens.access_token!); + + // Verify hasSession + expect(await mgr.auth.hasSession(mintUrl)).toBe(true); + + // Verify refresh_token if present + if (tokens.refresh_token) { + expect(session.refreshToken).toBe(tokens.refresh_token); + } + + console.log('Auth session established and AuthProvider wired'); + + // Logout + await mgr.auth.logout(mintUrl); + expect(await mgr.auth.hasSession(mintUrl)).toBe(false); + expect(mgr.auth.getAuthProvider(mintUrl)).toBeUndefined(); + console.log('Logout verified'); + }, 300000); // 5 min timeout for manual authorization +}); diff --git a/packages/core/test/unit/AuthApi.test.ts b/packages/core/test/unit/AuthApi.test.ts new file mode 100644 index 00000000..2e0142c5 --- /dev/null +++ b/packages/core/test/unit/AuthApi.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect, mock, beforeEach } from 'bun:test'; +import { AuthApi } from '../../api/AuthApi.ts'; +import type { AuthSessionService } from '../../services/AuthSessionService.ts'; +import type { MintAdapter } from '../../infra/MintAdapter.ts'; +import type { AuthSession } from '../../models/AuthSession.ts'; + +const mintUrl = 'https://mint.test'; +const normalizedUrl = 'https://mint.test'; + +const fakeSession: AuthSession = { + mintUrl: normalizedUrl, + accessToken: 'cat-token-abc', + refreshToken: 'refresh-xyz', + expiresAt: Math.floor(Date.now() / 1000) + 3600, + scope: undefined, +}; + +const expiredSession: AuthSession = { + mintUrl: normalizedUrl, + accessToken: 'expired-cat', + expiresAt: Math.floor(Date.now() / 1000) - 100, +}; + +function makeMocks() { + const authSessionService = { + saveSession: mock(async () => fakeSession), + deleteSession: mock(async () => {}), + getValidSession: mock(async () => fakeSession), + hasSession: mock(async () => true), + } as unknown as AuthSessionService; + + const mintAdapter = { + setAuthProvider: mock(() => {}), + clearAuthProvider: mock(() => {}), + } as unknown as MintAdapter; + + return { authSessionService, mintAdapter }; +} + +describe('AuthApi', () => { + let api: AuthApi; + let authSessionService: AuthSessionService; + let mintAdapter: MintAdapter; + + beforeEach(() => { + const mocks = makeMocks(); + authSessionService = mocks.authSessionService; + mintAdapter = mocks.mintAdapter; + api = new AuthApi(authSessionService, mintAdapter); + }); + + describe('login', () => { + it('persists session and wires AuthProvider into MintAdapter', async () => { + const session = await api.login(mintUrl, { + access_token: 'cat-token-abc', + expires_in: 3600, + }); + + expect(session).toBe(fakeSession); + expect(authSessionService.saveSession).toHaveBeenCalledTimes(1); + expect(mintAdapter.setAuthProvider).toHaveBeenCalledTimes(1); + + // AuthProvider should be cached + const provider = api.getAuthProvider(mintUrl); + expect(provider).toBeDefined(); + expect(provider!.getCAT()).toBe('cat-token-abc'); + }); + + it('sets CAT on AuthManager even without refresh_token', async () => { + await api.login(mintUrl, { access_token: 'no-refresh' }); + + const provider = api.getAuthProvider(mintUrl); + expect(provider).toBeDefined(); + expect(provider!.getCAT()).toBe('no-refresh'); + expect(mintAdapter.setAuthProvider).toHaveBeenCalledTimes(1); + }); + }); + + describe('logout', () => { + it('deletes session and clears AuthProvider', async () => { + // First login + await api.login(mintUrl, { access_token: 'cat-token-abc' }); + expect(api.getAuthProvider(mintUrl)).toBeDefined(); + + // Then logout + await api.logout(mintUrl); + + expect(authSessionService.deleteSession).toHaveBeenCalledTimes(1); + expect(mintAdapter.clearAuthProvider).toHaveBeenCalledTimes(1); + expect(api.getAuthProvider(mintUrl)).toBeUndefined(); + }); + }); + + describe('getSession', () => { + it('delegates to AuthSessionService.getValidSession', async () => { + const session = await api.getSession(mintUrl); + expect(session).toBe(fakeSession); + expect(authSessionService.getValidSession).toHaveBeenCalledWith(mintUrl); + }); + }); + + describe('hasSession', () => { + it('delegates to AuthSessionService.hasSession', async () => { + const result = await api.hasSession(mintUrl); + expect(result).toBe(true); + expect(authSessionService.hasSession).toHaveBeenCalledWith(mintUrl); + }); + }); + + describe('restore', () => { + it('returns false when no valid session exists', async () => { + const mocks = makeMocks(); + (mocks.authSessionService.getValidSession as ReturnType).mockImplementation( + async () => { + throw new Error('No session'); + }, + ); + const testApi = new AuthApi(mocks.authSessionService, mocks.mintAdapter); + + const result = await testApi.restore(mintUrl); + expect(result).toBe(false); + expect(mocks.mintAdapter.setAuthProvider).not.toHaveBeenCalled(); + }); + + it('restores CAT and wires AuthProvider for valid session', async () => { + const result = await api.restore(mintUrl); + + expect(result).toBe(true); + expect(mintAdapter.setAuthProvider).toHaveBeenCalledTimes(1); + + const provider = api.getAuthProvider(mintUrl); + expect(provider).toBeDefined(); + expect(provider!.getCAT()).toBe('cat-token-abc'); + }); + }); + + describe('getAuthProvider', () => { + it('returns undefined for unknown mint', () => { + expect(api.getAuthProvider('https://unknown.test')).toBeUndefined(); + }); + + it('returns AuthManager after login', async () => { + await api.login(mintUrl, { access_token: 'test' }); + const provider = api.getAuthProvider(mintUrl); + expect(provider).toBeDefined(); + expect(typeof provider!.getCAT).toBe('function'); + expect(typeof provider!.getBlindAuthToken).toBe('function'); + }); + }); +}); diff --git a/packages/core/test/unit/AuthSessionService.test.ts b/packages/core/test/unit/AuthSessionService.test.ts new file mode 100644 index 00000000..603763b0 --- /dev/null +++ b/packages/core/test/unit/AuthSessionService.test.ts @@ -0,0 +1,75 @@ +import type { CoreEvents } from '@core/events'; +import { EventBus } from '@core/events'; +import { MemoryAuthSessionRepository } from '@core/repositories'; +import { AuthSessionService } from '@core/services'; +import { beforeEach, describe, expect, it } from 'bun:test'; + + +describe('AuthSessionService', () => { + const mintUrl = 'https://mint.test' + let repo: MemoryAuthSessionRepository; + let bus: EventBus + let service: AuthSessionService; + + beforeEach(() => { + repo = new MemoryAuthSessionRepository(); + bus = new EventBus; + service = new AuthSessionService(repo,undefined, bus); + }); + + it('saves and retrieves a valid session', async() =>{ + await service.saveSession(mintUrl, { + access_token:'abc_123', + refresh_token:'def_456', + expires_in:3600, + }); + const session = await service.getValidSession(mintUrl); + + expect(session.accessToken).toBe('abc_123'); + expect(session.refreshToken).toBe('def_456'); + }); + + it('emits auth-session:updated on save', async() => { + const events: string[] = []; + bus.on('auth-session:updated', (payload) => { + events.push(payload.mintUrl); + }); + await service.saveSession(mintUrl, {access_token: 'abc'}); + + expect(events).toEqual([mintUrl]); + }) + + it('throws AuthSessionExpiredError on expired session', async()=> { + //save expired session for test + await repo.saveSession({ + mintUrl, + accessToken: 'old', + expiresAt: 0, + }); + await expect(service.getValidSession(mintUrl)).rejects.toThrow('expired'); + }); + + it('throws on non-existent session', async() => { + await expect(service.getValidSession(mintUrl)).rejects.toThrow('No auth session found'); + }) + + it('normalizes mint URL', async() => { + //save URL with '/' + await service.saveSession('https://mint.test/', {access_token:'abc'}); + + //get Session without '/' + const session = await service.getValidSession('https://mint.test'); + + expect(session.accessToken).toBe('abc'); + }); + + it('deletes session and emits event', async() =>{ + const events: string[] = []; + bus.on('auth-session:deleted', (p) => {events.push(p.mintUrl)} ); + await service.saveSession(mintUrl, {access_token: 'abc'}); + await service.deleteSession(mintUrl); + + await expect(service.getValidSession(mintUrl)).rejects.toThrow(); + expect(events).toEqual([mintUrl]); + }) +}); \ No newline at end of file