From 29ffb41bad3ade4e6e91d9920a464c4f1deae46c Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 29 Oct 2026 05:10:35 +0000 Subject: [PATCH] feat: Sign in with CoinPay (sub-bound; nonce on every authorization) CoinPay is the fleet master account: a person can now sign in here with CoinPay, not only connect it to an account they already have. - /auth/coinpay starts a sign-in (PKCE S256 + state + nonce), the state pinned to the browser by an HttpOnly cookie; the existing callback /api/v1/coinpay/callback finishes it and mints a session. - The account is found by coinpay_accounts.coinpay_sub (unique), never by the CoinPay email. A new CoinPay sign-in makes a new account with a random .invalid placeholder address, never an admin; .invalid can no longer be typed into sign-in or sign-up. - Connect (already PKCE + secret) now sends a nonce too and checks the id_token (iss/aud/exp/nonce, sub == userinfo sub) via @profullstack/coinpay-onboard 0.3.1. - Disconnect is refused while CoinPay is the only way in (placeholder address and no passkey). - "Sign in with CoinPay" first on /login and /signup; the CLI's device flow approves through /login, so it inherits CoinPay. - Migration 0027: coinpay_oauth_states gets mode + nonce, user_id nullable. Co-Authored-By: Claude Opus 5.5 --- migrations/0027_coinpay_sign_in.sql | 14 + package.json | 1 + pnpm-lock.yaml | 769 ++++++++++++++++++++++++++++ src/core/auth.ts | 3 + src/core/coinpay.ts | 213 ++++++-- src/server/routes/api.ts | 26 +- src/server/routes/pages.tsx | 66 ++- src/views/auth.tsx | 38 +- test/api.test.ts | 136 ++++- 9 files changed, 1222 insertions(+), 44 deletions(-) create mode 100644 migrations/0027_coinpay_sign_in.sql diff --git a/migrations/0027_coinpay_sign_in.sql b/migrations/0027_coinpay_sign_in.sql new file mode 100644 index 0000000..7944ea2 --- /dev/null +++ b/migrations/0027_coinpay_sign_in.sql @@ -0,0 +1,14 @@ +-- Sign in with CoinPay. +-- +-- CoinPay is the fleet's master account: a person can sign in here with it, not +-- only connect it to an account they already have. A sign-in has no account +-- yet when it starts, so a pending authorization may carry no user. Every +-- authorization also carries an OIDC nonce now, checked against the id_token +-- CoinPay returns. +-- +-- The account is found by coinpay_accounts.coinpay_sub (already unique), never +-- by the CoinPay email: CoinPay does not verify addresses. + +alter table coinpay_oauth_states alter column user_id drop not null; +alter table coinpay_oauth_states add column if not exists mode text not null default 'connect'; +alter table coinpay_oauth_states add column if not exists nonce text; diff --git a/package.json b/package.json index 2c2f299..2abe208 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "dependencies": { "@hono/node-server": "^2.1.5", "@profullstack/affiliates": "^0.2.0", + "@profullstack/coinpay-onboard": "0.3.1", "@profullstack/emailer": "^1.0.3", "@profullstack/footer": "latest", "@profullstack/hqtui": "^0.11.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2bfe6f0..4a42cbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@profullstack/affiliates': specifier: ^0.2.0 version: 0.2.0 + '@profullstack/coinpay-onboard': + specifier: 0.3.1 + version: 0.3.1 '@profullstack/emailer': specifier: ^1.0.3 version: 1.0.3 @@ -81,6 +84,16 @@ packages: '@levischuck/tiny-cbor@0.2.11': resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@modelcontextprotocol/sdk@1.32.2': + resolution: {integrity: sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@peculiar/asn1-android@2.11.1': resolution: {integrity: sha512-AuUBRDNdWrgtnuJGXARa0Xdtq6OAD8GqezDQz6Qka7kyynMo9fzkcrtpBEHuX984uEB570BrhMZfJfkUDfpQfw==} engines: {node: '>=14'} @@ -145,6 +158,11 @@ packages: engines: {node: '>=22.13'} hasBin: true + '@profullstack/coinpay-onboard@0.3.1': + resolution: {integrity: sha512-+K7D6uaX10y+0V5sg7To1jKwNG8iCNbN9SSW6q5ACzoiDNswMox3G7gzY/Swxd128klq7ecN2Ni4JVNr50nRIQ==} + engines: {node: '>=20'} + hasBin: true + '@profullstack/emailer@1.0.3': resolution: {integrity: sha512-VyAtCcfnSI6LOwJvWogCr3ZNmEsaZw+iuWrqnosLsdcKsbRyCrCmXigMNZJZEjlgYlR+lK//PwNPG+xtCFvWAw==} @@ -342,14 +360,262 @@ packages: cpu: [x64] os: [win32] + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + asn1js@3.1.0: resolution: {integrity: sha512-HWYi1THkOqguCiO+vwj09ZkQFD2kKEsejYHAYCtX0D80WVdoDnvRc9IukXX/jeb0T9NRWJnRtqwdbHltnFjwXA==} engines: {node: '>=12.0.0'} + body-parser@2.4.0: + resolution: {integrity: sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.9: + resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hono@4.13.14: resolution: {integrity: sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==} engines: {node: '>=16.9.0'} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.5: + resolution: {integrity: sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.2: + resolution: {integrity: sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pg-cloudflare@1.4.1: resolution: {integrity: sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==} @@ -384,6 +650,10 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -405,6 +675,10 @@ packages: engines: {node: '>=14'} hasBin: true + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -412,13 +686,79 @@ packages: resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} engines: {node: '>=16.0.0'} + qs@6.17.0: + resolution: {integrity: sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + tslib@1.14.1: resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} @@ -429,6 +769,10 @@ packages: resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} engines: {node: '>= 6.0.0'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -440,10 +784,34 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + snapshots: '@hexagon/base64@1.1.28': {} @@ -454,6 +822,28 @@ snapshots: '@levischuck/tiny-cbor@0.2.11': {} + '@modelcontextprotocol/sdk@1.32.2(zod@4.6.7)': + dependencies: + '@hono/node-server': 2.1.5(hono@4.13.14) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.14 + jose: 6.2.13 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + '@peculiar/asn1-android@2.11.1': dependencies: '@peculiar/asn1-schema': 2.11.1 @@ -576,6 +966,14 @@ snapshots: transitivePeerDependencies: - react + '@profullstack/coinpay-onboard@0.3.1': + dependencies: + '@modelcontextprotocol/sdk': 1.32.2(zod@4.6.7) + zod: 4.6.7 + transitivePeerDependencies: + - '@cfworker/json-schema' + - supports-color + '@profullstack/emailer@1.0.3': {} '@profullstack/footer@0.5.0': {} @@ -685,14 +1083,263 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.9 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + asn1js@3.1.0: dependencies: pvtsutils: 1.3.6 pvutils: 1.2.0 tslib: 2.8.1 + body-parser@2.4.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.2 + qs: 6.17.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.5 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.4.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.2 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.17.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.9: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.2 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hono@4.13.14: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.5: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.13: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.2: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + pg-cloudflare@1.4.1: optional: true @@ -728,6 +1375,8 @@ snapshots: dependencies: split2: 4.2.0 + pkce-challenge@5.0.1: {} + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -740,16 +1389,114 @@ snapshots: prettier@3.9.10: {} + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 pvutils@1.2.0: {} + qs@6.17.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + reflect-metadata@0.2.2: {} + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.2 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + split2@4.2.0: {} + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + tslib@1.14.1: {} tslib@2.8.1: {} @@ -758,6 +1505,12 @@ snapshots: dependencies: tslib: 1.14.1 + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -785,4 +1538,20 @@ snapshots: undici-types@8.9.0: {} + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + xtend@4.0.2: {} + + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} diff --git a/src/core/auth.ts b/src/core/auth.ts index a72016e..c9c268c 100644 --- a/src/core/auth.ts +++ b/src/core/auth.ts @@ -35,6 +35,9 @@ export const SESSION_COOKIE = 'aj_session'; export function normaliseEmail(value: unknown): string | null { const email = clean(value, 200).toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null; + // RFC 2606 reserves .invalid, and accounts Sign in with CoinPay made hold a + // placeholder there: nobody may type one in to sign up or sign in. + if (email.endsWith('.invalid')) return null; return email; } diff --git a/src/core/coinpay.ts b/src/core/coinpay.ts index d9846e2..4e407be 100644 --- a/src/core/coinpay.ts +++ b/src/core/coinpay.ts @@ -25,6 +25,7 @@ */ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { checkIdToken, sameState } from '@profullstack/coinpay-onboard/oauth'; import type pg from 'pg'; import type { CoinPayConfig } from '../config.ts'; @@ -47,6 +48,8 @@ export interface TokenSet { refreshToken: string | null; scope: string[]; expiresAt: string; + /** The OIDC id_token, when `openid` was granted. Checked, never stored. */ + idToken: string | null; } export interface UserInfo { @@ -80,7 +83,12 @@ export const PROPERTY = 'agenticjobs.work'; export interface CoinPayClient { readonly config: CoinPayConfig; - authorizeUrl(input: { state: string; codeChallenge: string; redirectUri: string }): string; + authorizeUrl(input: { + state: string; + codeChallenge: string; + redirectUri: string; + nonce?: string; + }): string; exchangeCode(input: { code: string; redirectUri: string; @@ -154,6 +162,7 @@ function tokenSet(body: Record): TokenSet { refreshToken: str(body['refresh_token']), scope: (str(body['scope']) ?? '').split(/\s+/).filter(Boolean), expiresAt: new Date(Date.now() + expiresIn * 1000).toISOString(), + idToken: str(body['id_token']), }; } @@ -240,7 +249,7 @@ export function createCoinPay(config: CoinPayConfig, fetchImpl: Fetch = fetch): return { config, - authorizeUrl({ state, codeChallenge, redirectUri }) { + authorizeUrl({ state, codeChallenge, redirectUri, nonce }) { const params = new URLSearchParams({ client_id: config.clientId, redirect_uri: redirectUri, @@ -249,6 +258,7 @@ export function createCoinPay(config: CoinPayConfig, fetchImpl: Fetch = fetch): state, code_challenge: codeChallenge, code_challenge_method: 'S256', + ...(nonce === undefined ? {} : { nonce }), }); return `${api}/oauth/authorize?${params.toString()}`; }, @@ -437,30 +447,97 @@ export async function beginConnect( redirectUri: string, next: string | null, ): Promise { + return (await beginAuthorization(pool, coinpay, { mode: 'connect', userId, redirectUri, next })) + .url; +} + +/** + * Begin "Sign in with CoinPay" for someone with no session. Returns the URL + * to send them to and the state, which the caller also pins to the browser in + * a cookie: a sign-in completed in a browser that did not start it would be + * login CSRF (someone else's account, signed in under your nose). + */ +export async function beginSignIn( + pool: pg.Pool, + coinpay: CoinPayClient, + redirectUri: string, + next: string | null, +): Promise<{ url: string; state: string }> { + return beginAuthorization(pool, coinpay, { mode: 'login', userId: null, redirectUri, next }); +} + +async function beginAuthorization( + pool: pg.Pool, + coinpay: CoinPayClient, + input: { + mode: 'connect' | 'login'; + userId: string | null; + redirectUri: string; + next: string | null; + }, +): Promise<{ url: string; state: string }> { const { verifier, challenge } = pkcePair(); const state = base64url(randomBytes(24)); + const nonce = base64url(randomBytes(24)); await pool.query(`delete from coinpay_oauth_states where expires_at < now()`); await pool.query( - `insert into coinpay_oauth_states (state, user_id, code_verifier, redirect, expires_at) - values ($1, $2, $3, $4, $5)`, - [state, userId, verifier, next, new Date(Date.now() + STATE_TTL_MS).toISOString()], + `insert into coinpay_oauth_states (state, user_id, code_verifier, redirect, expires_at, mode, nonce) + values ($1, $2, $3, $4, $5, $6, $7)`, + [ + state, + input.userId, + verifier, + input.next, + new Date(Date.now() + STATE_TTL_MS).toISOString(), + input.mode, + nonce, + ], ); - return coinpay.authorizeUrl({ state, codeChallenge: challenge, redirectUri }); + return { + url: coinpay.authorizeUrl({ + state, + codeChallenge: challenge, + redirectUri: input.redirectUri, + nonce, + }), + state, + }; } +/** What a finished CoinPay authorization did. */ +export type Finished = + | { kind: 'connected'; userId: string; redirect: string | null; account: Account } + | { + kind: 'signed-in'; + userId: string; + /** True when this sign-in made the account. */ + created: boolean; + redirect: string | null; + } + /** A sign-in that did not finish; the message is for the log, not the page. */ + | { kind: 'sign-in-failed'; message: string }; + +/** The placeholder address of an account Sign in with CoinPay made. */ +export const PLACEHOLDER_DOMAIN = 'coinpay.agenticjobs.invalid'; + /** - * Finish connecting. Returns where to send the person, or a sentence. + * Finish a CoinPay authorization: a connect started by a signed-in person, or + * a sign-in. Returns what happened, or a sentence for the person. * - * The account is attached to whoever *started* the flow, read off the state, + * A connect is attached to whoever *started* the flow, read off the state, * not to whoever is signed in when the callback lands: those are the same * person in every honest case and a different one in the case this protects - * against. + * against. A sign-in must land in the browser that started it + * (`browserState`, from a cookie set at the start). + * + * Either way the CoinPay account is matched by `sub`, never by email: CoinPay + * does not verify addresses, so its email is contact information only. */ export async function finishConnect( pool: pg.Pool, coinpay: CoinPayClient, - input: { state: string; code: string; redirectUri: string }, -): Promise<{ userId: string; redirect: string | null; account: Account } | string> { + input: { state: string; code: string; redirectUri: string; browserState?: string | null }, +): Promise { // The state travels into a SQL parameter, and a NUL byte is not text: // PostgreSQL refuses it outright, so `?state=%00` on // GET /api/v1/coinpay/callback answered 500 where every state that matches @@ -468,16 +545,24 @@ export async function finishConnect( // away to nothing matches nothing. const state = input.state.replace(/\0/g, '').trim(); const claimed = await pool.query<{ - user_id: string; + user_id: string | null; code_verifier: string; redirect: string | null; + mode: string; + nonce: string | null; }>( `delete from coinpay_oauth_states where state = $1 and expires_at > now() - returning user_id, code_verifier, redirect`, + returning user_id, code_verifier, redirect, mode, nonce`, [state], ); const pending = claimed.rows[0]; if (pending === undefined) return 'That connection attempt has expired. Start again.'; + const signIn = pending.mode === 'login'; + const failed = (message: string): Finished | string => + signIn ? { kind: 'sign-in-failed', message } : message; + if (signIn && !sameState(input.browserState, state)) { + return failed('That sign-in did not start in this browser. Start again.'); + } let tokens: TokenSet; let info: UserInfo; @@ -487,12 +572,32 @@ export async function finishConnect( redirectUri: input.redirectUri, codeVerifier: pending.code_verifier, }); - info = await coinpay.userinfo(tokens.accessToken); + // Every authorization since 0027 carries a nonce; one already in flight + // when that shipped has none and is finished the way it was started. + if (pending.nonce !== null || signIn) { + const claims = checkIdToken({ + idToken: tokens.idToken, + clientId: coinpay.config.clientId, + nonce: pending.nonce ?? '', + issuer: coinpay.config.url, + }); + info = await coinpay.userinfo(tokens.accessToken); + if (claims.sub !== info.sub) + throw new CoinPayProblem('CoinPay answered for two different people.'); + } else { + info = await coinpay.userinfo(tokens.accessToken); + } } catch (error) { - if (error instanceof CoinPayProblem) return error.message; + if (error instanceof CoinPayProblem) return failed(error.message); + if (error instanceof Error && /^id_token|CoinPay returned no id_token/.test(error.message)) { + return failed(`CoinPay sign-in could not be verified (${error.message}). Start again.`); + } throw error; } + if (signIn) return finishSignIn(pool, tokens, info, pending.redirect); + if (pending.user_id === null) return 'That connection attempt has expired. Start again.'; + if (!hasScope(tokens.scope)) { return `CoinPay connected the account but did not grant ${REQUIRED_SCOPE}, so no wallet can be read. Check the OAuth client's registered scopes on CoinPay.`; } @@ -506,7 +611,17 @@ export async function finishConnect( return 'That CoinPay account is already connected to a different account on this board. Disconnect it there first.'; } - // The wallets are what the board needs; the tokens are not stored at all. + const account = await saveAccount(pool, pending.user_id, tokens, info); + return { kind: 'connected', userId: pending.user_id, redirect: pending.redirect, account }; +} + +/** The wallets are what the board needs; the tokens are not stored at all. */ +async function saveAccount( + pool: pg.Pool, + userId: string, + tokens: TokenSet, + info: UserInfo, +): Promise { // A reconnect over a legacy row clears its old tokens in the same write. const saved = await pool.query( `insert into coinpay_accounts @@ -519,20 +634,60 @@ export async function finishConnect( scope = excluded.scope, wallets = excluded.wallets, wallets_synced_at = now(), updated_at = now() returning *`, - [ - pending.user_id, - info.sub, - info.email, - info.name, - tokens.scope.join(' '), - JSON.stringify(info.wallets), - ], + [userId, info.sub, info.email, info.name, tokens.scope.join(' '), JSON.stringify(info.wallets)], ); - return { - userId: pending.user_id, - redirect: pending.redirect, - account: toAccount(saved.rows[0]!), - }; + return toAccount(saved.rows[0]!); +} + +/** + * Sign in with CoinPay. The `sub` decides the account: the one it is already + * connected to, or a new one. Never an existing account with the same email. + * A new account gets a random, unroutable placeholder address (contact + * email stays on the CoinPay connection) and is never an administrator. + */ +async function finishSignIn( + pool: pg.Pool, + tokens: TokenSet, + info: UserInfo, + redirect: string | null, +): Promise { + const linked = await pool.query<{ user_id: string }>( + `select user_id from coinpay_accounts where coinpay_sub = $1`, + [info.sub], + ); + const existing = linked.rows[0]?.user_id; + if (existing !== undefined) { + // Wallets refreshed only when this grant could read them. + if (hasScope(tokens.scope)) await saveAccount(pool, existing, tokens, info); + return { kind: 'signed-in', userId: existing, created: false, redirect }; + } + + const placeholder = `${base64url(randomBytes(12)) + .toLowerCase() + .replace(/[^a-z0-9]/g, '')}@${PLACEHOLDER_DOMAIN}`; + const created = await pool.query<{ id: string }>( + `insert into users (email, name, is_admin) values ($1, $2, false) returning id`, + [placeholder, info.name === null ? null : info.name.slice(0, 120)], + ); + const userId = created.rows[0]!.id; + await saveAccount(pool, userId, tokens, info); + return { kind: 'signed-in', userId, created: true, redirect }; +} + +/** + * Whether a person can still get in without CoinPay: a real address for the + * magic link, or a passkey. An account Sign in with CoinPay made has neither + * until they add a passkey, so disconnecting would lock them out. + */ +export async function hasOtherSignIn(pool: pg.Pool, userId: string): Promise { + const result = await pool.query<{ email: string; passkeys: number }>( + `select u.email, (select count(*)::int from passkeys p where p.user_id = u.id) as passkeys + from users u where u.id = $1`, + [userId], + ); + const row = result.rows[0]; + if (row === undefined) return false; + return !row.email.toLowerCase().endsWith(`@${PLACEHOLDER_DOMAIN}`) || row.passkeys > 0; } // --- legacy tokens --------------------------------------------------------- diff --git a/src/server/routes/api.ts b/src/server/routes/api.ts index 531c785..4d4e389 100644 --- a/src/server/routes/api.ts +++ b/src/server/routes/api.ts @@ -137,8 +137,9 @@ import { sendInvoice, syncInvoice, } from '../../core/invoices.ts'; -import { disconnect, finishConnect, getAccount } from '../../core/coinpay.ts'; -import { coinpayRedirectUri } from './pages.tsx'; +import { disconnect, finishConnect, getAccount, hasOtherSignIn } from '../../core/coinpay.ts'; +import { getCookie, deleteCookie } from 'hono/cookie'; +import { COINPAY_STATE_COOKIE, coinpayRedirectUri, setSessionCookie } from './pages.tsx'; import { descriptorFor } from './descriptor.ts'; import { openApiDocument } from './openapi.ts'; import { afterPublish, countView } from '../publish.ts'; @@ -1785,6 +1786,14 @@ export function apiRoutes(): Hono { const { pool } = c.get('deps'); const viewer = viewerOf(c); if (viewer === null) return fail(c, 401, 'unauthenticated', 'Sign in first.'); + if (!(await hasOtherSignIn(pool, viewer.id))) { + return fail( + c, + 409, + 'only_sign_in', + 'CoinPay is the only way into this account. Add a passkey first, then disconnect.', + ); + } return c.json({ ok: await disconnect(pool, viewer.id) }); }); @@ -1798,6 +1807,9 @@ export function apiRoutes(): Hono { const params = new URL(c.req.url).searchParams; const code = params.get('code') ?? ''; const state = params.get('state') ?? ''; + // Set by /auth/coinpay for a sign-in; spent whatever happens next. + const browserState = getCookie(c, COINPAY_STATE_COOKIE) ?? null; + if (browserState !== null) deleteCookie(c, COINPAY_STATE_COOKIE, { path: '/api/v1/coinpay' }); if (params.get('error') !== null) { const said = params.get('error_description') ?? params.get('error') ?? 'refused'; return c.redirect(`/me?coinpay=${encodeURIComponent(`CoinPay said: ${said}`)}#billing`, 303); @@ -1809,10 +1821,20 @@ export function apiRoutes(): Hono { state, code, redirectUri: coinpayRedirectUri(config.publicUrl), + browserState, }); if (typeof done === 'string') { return c.redirect(`/me?coinpay=${encodeURIComponent(done)}#billing`, 303); } + if (done.kind === 'sign-in-failed') { + console.warn(`[coinpay] sign-in failed: ${done.message}`); + return c.redirect('/login?coinpay=failed', 303); + } + if (done.kind === 'signed-in') { + const session = await createSession(pool, done.userId, { label: 'browser' }); + setSessionCookie(c, session); + return c.redirect(done.redirect ?? (done.created ? '/me?coinpay=welcome' : '/me'), 303); + } const back = done.redirect ?? '/me#billing'; if (back.startsWith('/me')) return c.redirect('/me?coinpay=connected#billing', 303); return c.redirect(`${back}${back.includes('?') ? '&' : '?'}coinpay=connected`, 303); diff --git a/src/server/routes/pages.tsx b/src/server/routes/pages.tsx index a3fee41..d4c5c23 100644 --- a/src/server/routes/pages.tsx +++ b/src/server/routes/pages.tsx @@ -133,7 +133,13 @@ import { writerProvider, } from '../../core/agentwriter.ts'; import { readSpec } from './specs.ts'; -import { beginConnect, disconnect, getAccount } from '../../core/coinpay.ts'; +import { + beginConnect, + beginSignIn, + disconnect, + getAccount, + hasOtherSignIn, +} from '../../core/coinpay.ts'; import { listInvoicesFor } from '../../core/invoices.ts'; import { BillingCard } from '../../views/inbox.tsx'; import { AgentsCard } from '../../views/agents.tsx'; @@ -176,8 +182,13 @@ export function coinpayRedirectUri(publicUrl: string): string { return `${publicUrl}/api/v1/coinpay/callback`; } +/** Pins a Sign in with CoinPay to the browser that started it (login CSRF). */ +export const COINPAY_STATE_COOKIE = 'aj_coinpay_state'; + /** The one-word states the callback redirects with, said in a sentence. */ export function coinpayNotice(code: string): string { + if (code === 'welcome') + return 'Welcome. This account was made with CoinPay, which is how you sign in. Add a passkey below for a second way in.'; if (code === 'connected') return 'CoinPay connected. Invoices you send settle to the wallets below.'; if (code === 'disconnected') @@ -896,12 +907,46 @@ export function pageRoutes(): Hono { // --- sign in ---------------------------------------------------------- - pages.get('/login', (c) => { + /** + * Sign in with CoinPay: off to CoinPay's consent, back to + * /api/v1/coinpay/callback. The state is pinned to this browser by a cookie. + */ + pages.get('/auth/coinpay', async (c) => { + const { pool, coinpay, config } = c.get('deps'); + if (coinpay === null) return c.redirect('/login', 302); if (c.get('viewer') !== null) return c.redirect('/me', 302); const next = safeRedirect(new URL(c.req.url).searchParams.get('next')); + const { url, state } = await beginSignIn( + pool, + coinpay, + coinpayRedirectUri(config.publicUrl), + next, + ); + setCookie(c, COINPAY_STATE_COOKIE, state, { + path: '/api/v1/coinpay', + httpOnly: true, + // Lax: the callback is a top-level navigation back from CoinPay. + sameSite: 'Lax', + secure: config.publicUrl.startsWith('https://'), + maxAge: 10 * 60, + }); + return c.redirect(url, 302); + }); + + pages.get('/login', (c) => { + if (c.get('viewer') !== null) return c.redirect('/me', 302); + const params = new URL(c.req.url).searchParams; + const next = safeRedirect(params.get('next')); + const notice = params.get('coinpay'); return c.html( - + , ); }); @@ -912,7 +957,11 @@ export function pageRoutes(): Hono { const invite = pendingInvite(c); return c.html( - + , ); }); @@ -938,6 +987,7 @@ export function pageRoutes(): Hono { return c.html( { const { pool } = c.get('deps'); const viewer = requireViewer(c); if (viewer instanceof Response) return viewer; + if (!(await hasOtherSignIn(pool, viewer.id))) { + return c.redirect( + `/me?coinpay=${encodeURIComponent('CoinPay is the only way into this account. Add a passkey first, then disconnect.')}#billing`, + 303, + ); + } await disconnect(pool, viewer.id); return c.redirect('/me?coinpay=disconnected#billing', 303); }); @@ -1744,7 +1800,7 @@ function creditNoticeOf( }; } -function setSessionCookie(c: Ctx, token: string): void { +export function setSessionCookie(c: Ctx, token: string): void { const { config } = c.get('deps'); setCookie(c, SESSION_COOKIE, token, { path: '/', diff --git a/src/views/auth.tsx b/src/views/auth.tsx index 12a1700..9bc2acf 100644 --- a/src/views/auth.tsx +++ b/src/views/auth.tsx @@ -18,12 +18,33 @@ import { Alert, Card, Field } from './layout.tsx'; */ export const SIGNUP_FIELDS = PROFILE_COLUMNS.filter((field) => field !== 'email'); -export const LoginPage: FC<{ sent?: string; error?: string; next?: string; unsent?: boolean }> = ({ - sent, - error, - next, - unsent, -}) => ( +/** + * Sign in with CoinPay: the first option on sign-in and sign-up. One CoinPay + * account signs in across every Profullstack site; it binds to the CoinPay + * account itself, never to an email address. + */ +export const CoinPayButton: FC<{ next?: string }> = ({ next }) => ( + + + Sign in with CoinPay + +

+ Your CoinPay account signs you in here. New to this board? It makes your account. +

+
+); + +export const LoginPage: FC<{ + sent?: string; + error?: string; + next?: string; + unsent?: boolean; + /** Sign in with CoinPay is configured on this board. */ + coinpay?: boolean; +}> = ({ sent, error, next, unsent, coinpay }) => (

Sign in

@@ -41,6 +62,7 @@ export const LoginPage: FC<{ sent?: string; error?: string; next?: string; unsen ) : ( <> + {coinpay === true && }
{next !== undefined && } @@ -88,7 +110,8 @@ export const SignupPage: FC<{ errors?: Record; /** An invite code from /i/, carried on the link this form asks for. */ invite?: string; -}> = ({ next, values = {}, errors = {}, invite }) => ( + coinpay?: boolean; +}> = ({ next, values = {}, errors = {}, invite, coinpay }) => (

Create your account

@@ -100,6 +123,7 @@ export const SignupPage: FC<{ {Object.keys(errors).length > 0 && ( Some fields need fixing. )} + {coinpay === true && } {next !== undefined && } diff --git a/test/api.test.ts b/test/api.test.ts index 0401190..bbe50ee 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1834,6 +1834,24 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, let scopeToGrant = 'openid profile email wallet:read'; /** Who CoinPay says the token belongs to. One CoinPay account, one person here, so each test is its own merchant. */ let subToGrant = 'merchant-default'; + /** The email CoinPay reports: unverified contact info, never an account key. */ + let emailToGrant = 'grace@coinpay.test'; + /** The nonce the last authorize URL carried, echoed in the id_token like CoinPay does. */ + let nonceToGrant = ''; + const idTokenFor = (sub: string, nonce: string) => + [ + Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'), + Buffer.from( + JSON.stringify({ + iss: 'https://coinpay.test', + aud: 'cp_board', + sub, + exp: Math.floor(Date.now() / 1000) + 600, + nonce, + }), + ).toString('base64url'), + 'sig', + ].join('.'); const payments = new Map>(); const WEBHOOK_SECRET = 'whsec_test_board'; const fetchStub: typeof fetch = async (input, init) => { @@ -1863,6 +1881,9 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, token_type: 'Bearer', expires_in: 3600, scope: scopeToGrant, + ...(scopeToGrant.includes('openid') + ? { id_token: idTokenFor(subToGrant, nonceToGrant) } + : {}), }); } if (url.pathname === '/api/oauth/userinfo') { @@ -1870,7 +1891,8 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, if (!auth.startsWith('Bearer at_')) return json({ error: 'invalid_token' }, 401); return json({ sub: subToGrant, - email: 'grace@coinpay.test', + email: emailToGrant, + email_verified: false, name: 'Grace', ...(scopeToGrant.includes('wallet:read') ? { @@ -1978,6 +2000,9 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, 'http://board.test/api/v1/coinpay/callback', ); assert.ok(authorize.searchParams.get('scope')?.includes('wallet:read')); + assert.equal(authorize.searchParams.get('code_challenge_method'), 'S256'); + nonceToGrant = authorize.searchParams.get('nonce') ?? ''; + assert.ok(nonceToGrant.length > 20, 'every authorization carries a nonce'); const state = authorize.searchParams.get('state') ?? ''; // The callback is completed by the state, not by the cookie. return send( @@ -2492,6 +2517,115 @@ describe('the API', { skip: reason === '' ? false : `no database: ${reason}` }, assert.equal(await legacyTokenCount(pool as never), 0); }); + /** Sign in with CoinPay the way a browser would: start, consent, callback with the pinned cookie. */ + const signIn = async (opts: { cookie?: boolean; nonce?: string } = {}) => { + const away = await send('GET', '/auth/coinpay?next=/me'); + assert.equal(away.status, 302); + const authorize = new URL(away.headers.get('location') ?? ''); + assert.equal( + authorize.origin + authorize.pathname, + 'https://coinpay.test/api/oauth/authorize', + ); + assert.equal(authorize.searchParams.get('code_challenge_method'), 'S256'); + assert.ok(authorize.searchParams.get('code_challenge')); + assert.equal( + authorize.searchParams.get('redirect_uri'), + 'http://board.test/api/v1/coinpay/callback', + ); + const state = authorize.searchParams.get('state') ?? ''; + nonceToGrant = opts.nonce ?? authorize.searchParams.get('nonce') ?? ''; + const pinned = /aj_coinpay_state=([^;]+)/.exec(away.headers.get('set-cookie') ?? '')?.[1]; + assert.equal(pinned, state, 'the state is pinned to the browser'); + const back = await send( + 'GET', + `/api/v1/coinpay/callback?code=good-code&state=${encodeURIComponent(state)}`, + undefined, + opts.cookie === false ? {} : { cookie: `aj_coinpay_state=${state}` }, + ); + const session = /aj_session=([^;]+)/.exec(back.headers.get('set-cookie') ?? '')?.[1]; + return { back, session }; + }; + + const whoIs = async (session: string | undefined) => { + const { viewerFromToken } = await import('../dist/core/auth.js'); + return session === undefined ? null : viewerFromToken(pool as never, session); + }; + + test('Sign in with CoinPay is first on /login and /signup', async () => { + if (pool === null) return; + for (const path of ['/login', '/signup']) { + const html = await (await send('GET', path)).text(); + const button = html.indexOf('Sign in with CoinPay'); + assert.ok(button > 0, `${path} offers CoinPay`); + assert.ok(button < html.indexOf('Email me a link'), `${path} puts CoinPay first`); + } + }); + + test('a first CoinPay sign-in makes a new account bound to the sub, never an admin', async () => { + if (pool === null) return; + subToGrant = `signin-new-${Date.now()}`; + emailToGrant = `fresh-${Date.now()}@example.com`; + const { back, session } = await signIn(); + assert.equal(back.status, 303); + assert.equal(back.headers.get('location'), '/me'); + const viewer = await whoIs(session); + assert.ok(viewer, 'signed in'); + assert.match(viewer.email, /@coinpay\.agenticjobs\.invalid$/); + assert.equal(viewer.isAdmin, false); + // The same CoinPay account signs into the same board account again. + const again = await signIn(); + assert.equal((await whoIs(again.session))?.id, viewer.id); + // And CoinPay is its only way in, so it cannot be disconnected. + const refused = await send('DELETE', '/api/v1/coinpay', undefined, { + authorization: `Bearer ${session}`, + }); + assert.equal(refused.status, 409); + emailToGrant = 'grace@coinpay.test'; + }); + + test('a CoinPay email equal to an existing account does NOT sign into that account', async () => { + if (pool === null) return; + const victim = await person('Victim'); + subToGrant = `signin-attacker-${Date.now()}`; + emailToGrant = victim.user.email; + const { session } = await signIn(); + const viewer = await whoIs(session); + assert.ok(viewer); + assert.notEqual(viewer.id, victim.user.id); + assert.notEqual(viewer.email.toLowerCase(), victim.user.email.toLowerCase()); + const linked = await pool.query(`select 1 from coinpay_accounts where user_id = $1`, [ + victim.user.id, + ]); + assert.equal(linked.rows.length, 0); + emailToGrant = 'grace@coinpay.test'; + }); + + test('a connected CoinPay account signs into the account that connected it', async () => { + if (pool === null) return; + const grace = await candidate('Grace Signs In'); + subToGrant = `signin-linked-${Date.now()}`; + assert.equal((await connect(grace)).status, 303); + const { session } = await signIn(); + assert.equal((await whoIs(session))?.id, grace.user.id); + // She has a real address too, so she may disconnect. + assert.equal((await send('DELETE', '/api/v1/coinpay', undefined, grace.auth)).status, 200); + }); + + test('a sign-in without the pinned cookie, or with a wrong nonce, is refused', async () => { + if (pool === null) return; + subToGrant = `signin-csrf-${Date.now()}`; + const noCookie = await signIn({ cookie: false }); + assert.equal(noCookie.session, undefined); + assert.equal(noCookie.back.headers.get('location'), '/login?coinpay=failed'); + const badNonce = await signIn({ nonce: 'not-the-nonce' }); + assert.equal(badNonce.session, undefined); + assert.equal(badNonce.back.headers.get('location'), '/login?coinpay=failed'); + const made = await pool.query(`select 1 from coinpay_accounts where coinpay_sub = $1`, [ + subToGrant, + ]); + assert.equal(made.rows.length, 0); + }); + test('a stale or reused state is refused', async () => { if (pool === null) return; assert.equal(