diff --git a/npmDepsHash b/npmDepsHash index 81fafbc4..2bf8a570 100644 --- a/npmDepsHash +++ b/npmDepsHash @@ -1 +1 @@ -sha256-23Yx1RJcaNEL1YmNz/LRFYL+5g++B+aoT0CSSbreAx8= +sha256-XCPXt+ESgufgJBBTvy+TZBjcovFef4pW/E0f4/JaAc0= diff --git a/package-lock.json b/package-lock.json index ea1f899e..5f2ea52a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "mocked-env": "^1.3.5", "nexpect": "^0.6.0", "node-gyp-build": "^4.8.4", + "open": "^10.1.2", "polykey": "^2.4.0", "shelljs": "^0.8.5", "shx": "^0.3.4", @@ -4292,6 +4293,21 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4869,6 +4885,34 @@ "node": ">=0.10.0" } }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4886,6 +4930,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -7269,6 +7325,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7341,6 +7412,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", @@ -7536,6 +7625,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -9244,6 +9348,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/open/-/open-10.1.2.tgz", + "integrity": "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw==", + "dev": true, + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", @@ -10314,6 +10436,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/run-applescript": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", + "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", diff --git a/package.json b/package.json index bf08a75d..8f3af96b 100644 --- a/package.json +++ b/package.json @@ -126,21 +126,21 @@ }, "optionalDependencies": { "@matrixai/db": "*", + "@matrixai/exec-darwin-universal": "*", + "@matrixai/exec-linux-x64": "*", "@matrixai/mdns-linux-x64": "*", "@matrixai/quic-darwin-universal": "*", "@matrixai/quic-linux-x64": "*", "@matrixai/quic-win32-x64": "*", - "@matrixai/exec-darwin-universal": "*", - "@matrixai/exec-linux-x64": "*", "fd-lock": "*", "sodium-native": "*" }, "devDependencies": { - "@matrixai/lint": "^0.2.11", + "@fast-check/jest": "^2.1.1", "@matrixai/errors": "^2.1.3", - "@matrixai/logger": "^4.0.3", "@matrixai/exec": "^1.0.3", - "@fast-check/jest": "^2.1.1", + "@matrixai/lint": "^0.2.11", + "@matrixai/logger": "^4.0.3", "@swc/core": "1.3.82", "@swc/jest": "^0.2.29", "@types/jest": "^29.5.14", @@ -161,6 +161,7 @@ "mocked-env": "^1.3.5", "nexpect": "^0.6.0", "node-gyp-build": "^4.8.4", + "open": "^10.1.2", "polykey": "^2.4.0", "shelljs": "^0.8.5", "shx": "^0.3.4", diff --git a/src/auth/CommandLogin.ts b/src/auth/CommandLogin.ts index 43df0afb..55a55452 100644 --- a/src/auth/CommandLogin.ts +++ b/src/auth/CommandLogin.ts @@ -1,35 +1,24 @@ import type PolykeyClient from 'polykey/PolykeyClient.js'; -import type { - TokenPayloadEncoded, - TokenProtectedHeaderEncoded, - TokenSignatureEncoded, -} from 'polykey/tokens/types.js'; -import type { IdentityRequestData } from 'polykey/client/types.js'; import CommandPolykey from '../CommandPolykey.js'; import * as binProcessors from '../utils/processors.js'; -import * as binParsers from '../utils/parsers.js'; import * as binUtils from '../utils/index.js'; import * as binOptions from '../utils/options.js'; -import * as errors from '../errors.js'; class CommandLogin extends CommandPolykey { constructor(...args: ConstructorParameters) { super(...args); this.name('login'); this.description('Login to a platform with Polykey identity'); - this.argument( - '', - 'Token provided by platform for logging in', - binParsers.parseCompactJWT, - ); + this.argument('', 'The URL to login using Polykey'); this.addOption(binOptions.nodeId); this.addOption(binOptions.clientHost); this.addOption(binOptions.clientPort); - this.action(async (encodedToken, options) => { + this.addOption(binOptions.returnURLPath); + this.action(async (url: string, options) => { const { default: PolykeyClient } = await import( 'polykey/PolykeyClient.js' ); - const tokensUtils = await import('polykey/tokens/utils.js'); + const { default: open } = await import('open'); const clientOptions = await binProcessors.processClientOptions( options.nodePath, options.nodeId, @@ -58,51 +47,33 @@ class CommandLogin extends CommandPolykey { logger: this.logger.getChild(PolykeyClient.name), }); - // Create a JSON representation of the encoded header - const [protectedHeader, payload, signature] = encodedToken; - const incomingTokenEncoded = { - payload: payload as TokenPayloadEncoded, - signatures: [ - { - protected: protectedHeader as TokenProtectedHeaderEncoded, - signature: signature as TokenSignatureEncoded, - }, - ], - }; - - // Get it verified and signed by the agent + // Get a signed token by the agent const response = await binUtils.retryAuthentication( (auth) => - pkClient.rpcClient.methods.authSignToken({ - metadata: auth, - ...incomingTokenEncoded, - }), + pkClient.rpcClient.methods.authIdentityToken({ metadata: auth }), meta, ); // Send the returned JWT to the returnURL provided by the initial token const compactHeader = binUtils.jsonToCompactJWT(response); - const incomingPayload = - tokensUtils.parseTokenPayload(payload); - let result: Response; + const targetURL = new URL( + url.endsWith('/') ? url.slice(0, url.length) : url, + ); + const subPath: string = options.returnURLPath ?? '/oauth2/oidc'; + targetURL.pathname = subPath.startsWith('/') ? subPath : `/${subPath}`; + targetURL.searchParams.append('token', compactHeader); + + // Print out the URL to stderr + process.stderr.write( + `Open the following URL in your browser:\n\t${targetURL}\n`, + ); + + // Try to open the URL in the browser try { - result = await fetch(incomingPayload.returnURL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: compactHeader }), - }); + process.stderr.write('Opening URL in browser...\n'); + await open(targetURL.toString()); } catch (e) { - throw new errors.ErrorPolykeyCLILoginFailed( - 'Failed to send token to return url', - { cause: e }, - ); - } - - // Handle non-200 response - if (!result.ok) { - throw new errors.ErrorPolykeyCLILoginFailed( - `Return url returned failure with code ${result.status}`, - ); + process.stderr.write(`Failed to open browser: ${e.message}\n`); } } finally { if (pkClient! != null) await pkClient.stop(); diff --git a/src/errors.ts b/src/errors.ts index 469715ee..e44809dd 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -201,11 +201,6 @@ class ErrorPolykeyCLIInvalidJWT extends ErrorPolykeyCLI { exitCode = sysexits.USAGE; } -class ErrorPolykeyCLILoginFailed extends ErrorPolykeyCLI { - static description = 'Failed to login using Polykey'; - exitCode = sysexits.SOFTWARE; -} - export { ErrorPolykeyCLI, ErrorPolykeyCLIUncaughtException, @@ -235,5 +230,4 @@ export { ErrorPolykeyCLIEditSecret, ErrorPolykeyCLITouchSecret, ErrorPolykeyCLIInvalidJWT, - ErrorPolykeyCLILoginFailed, }; diff --git a/src/utils/options.ts b/src/utils/options.ts index d3fa9fea..1146c154 100644 --- a/src/utils/options.ts +++ b/src/utils/options.ts @@ -320,6 +320,11 @@ const preserveNewline = new Option( }) .default([]); +const returnURLPath = new Option( + '--url-path ', + 'Which path on the website to send the token to', +); + export { nodePath, format, @@ -365,4 +370,5 @@ export { recursive, parents, preserveNewline, + returnURLPath, }; diff --git a/src/utils/parsers.ts b/src/utils/parsers.ts index 43eff651..6b98f5bd 100644 --- a/src/utils/parsers.ts +++ b/src/utils/parsers.ts @@ -193,30 +193,6 @@ const parsePort: (data: string) => Port = validateParserToArgParser( const parseSeedNodes: (data: string) => [SeedNodes, boolean] = validateParserToArgParser(nodesUtils.parseSeedNodes); -// Compact JWTs are in xxxx.yyyy.zzzz format where x is the protected -// header, y is the payload, and z is the binary signature. -const parseCompactJWT = (token: string): [string, string, string] => { - // Clean up whitespaces - token = token.trim(); - - // Confirm part amount - const parts = token.split('.'); - if (parts.length !== 3) { - throw new InvalidArgumentError( - 'JWT must contain three dot-separated parts', - ); - } - - // Validate base64 encoding - for (const part of parts) { - if (!part || !base64UrlRegex.test(part)) { - throw new InvalidArgumentError('JWT is not correctly encoded'); - } - } - - return [parts[0], parts[1], parts[2]]; -}; - export { vaultNameRegex, secretPathRegex, @@ -245,5 +221,4 @@ export { parseProviderId, parseIdentityId, parseProviderIdList, - parseCompactJWT, }; diff --git a/tests/auth/login.test.ts b/tests/auth/login.test.ts index d119cb79..121340eb 100644 --- a/tests/auth/login.test.ts +++ b/tests/auth/login.test.ts @@ -1,24 +1,20 @@ -import type { - IdentityRequestData, - IdentityResponseData, -} from 'polykey/client/types.js'; import type { TokenPayloadEncoded, TokenProtectedHeaderEncoded, TokenSignatureEncoded, } from 'polykey/tokens/types.js'; import path from 'node:path'; +import type open from 'open'; import fs from 'node:fs'; -import fc from 'fast-check'; import { jest } from '@jest/globals'; import { test } from '@fast-check/jest'; +import { spawn } from 'node:child_process'; import Logger, { LogLevel, StreamHandler } from '@matrixai/logger'; import PolykeyAgent from 'polykey/PolykeyAgent.js'; import Token from 'polykey/tokens/Token.js'; import * as keysUtils from 'polykey/keys/utils/index.js'; import * as nodesUtils from 'polykey/nodes/utils.js'; import * as testUtils from '../utils/index.js'; -import * as utils from '#utils/utils.js'; describe('commandAuthLogin', () => { const password = 'password'; @@ -27,14 +23,10 @@ describe('commandAuthLogin', () => { let polykeyAgent: PolykeyAgent; beforeEach(async () => { - jest.spyOn(globalThis, 'fetch').mockImplementation(() => - Promise.resolve( - new Response(JSON.stringify({ result: 'success' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ), - ); + // Mock implementation which spawns a noop child process + jest.unstable_mockModule('open', () => ({ + default: jest.fn().mockResolvedValue(spawn('true')), + })); dataDir = await fs.promises.mkdtemp( path.join(globalThis.tmpDir, 'polykey-test-'), ); @@ -62,42 +54,30 @@ describe('commandAuthLogin', () => { }); }); - test('should succeed with a valid compact JWT', async () => { - // Generate and sign token - const keyPair = keysUtils.generateKeyPair(); - const returnURL = 'test'; - const publicKey = keyPair.publicKey.toString('base64url'); - const token = Token.fromPayload({ - publicKey, - returnURL, - }); - token.signWithPrivateKey(keyPair.privateKey); - const encodedToken = utils.jsonToCompactJWT(token.toEncoded()); - - // Use token to try and login - const command = ['auth', 'login', '-np', dataDir, encodedToken]; + test('should return a valid, signed, and compact JWT', async () => { + // Try and login to a mock site + jest.useFakeTimers(); + const returnURL = 'https://testing123.com'; + const command = ['auth', 'login', '-np', dataDir, returnURL]; const result = await testUtils.pkStdio(command, { env: { PK_PASSWORD: password }, cwd: dataDir, }); expect(result.exitCode).toBe(0); - // Check the received token - const fetchMock = globalThis.fetch as jest.MockedFunction; - expect(fetchMock).toHaveBeenCalled(); - const [url, options] = fetchMock.mock.lastCall!; - expect(url).toBe(returnURL); - expect(options).toBeDefined(); - expect(options!.method).toBe('POST'); + // Check the received url + const { default: open } = await import('open'); + const openMock = open as jest.MockedFunction; + expect(openMock).toHaveBeenCalled(); + const [url] = openMock.mock.lastCall!; + const populatedURL = new URL(url); + expect(populatedURL.origin).toBe(returnURL); + expect(populatedURL.searchParams.has('token')); // Reconstruct the token - expect(typeof options!.body).toBe('string'); - const responseBody: { token: string } = JSON.parse( - options!.body! as string, - ); - const receivedEncodedToken = responseBody.token; + const receivedEncodedToken = populatedURL.searchParams.get('token')!; const [header, payload, signature] = receivedEncodedToken.split('.'); - const receivedToken = Token.fromEncoded({ + const receivedToken = Token.fromEncoded({ payload: payload as TokenPayloadEncoded, signatures: [ { @@ -108,55 +88,11 @@ describe('commandAuthLogin', () => { }); // Verify the incoming token. The nodeId is the node's public key. - const nodeId = nodesUtils.decodeNodeId(receivedToken.payload.nodeId); + const nodeId = nodesUtils.decodeNodeId(receivedToken.payload.iss); expect(nodeId).toBeDefined(); const nodeIdPublicKey = keysUtils.publicKeyFromNodeId(nodeId!); expect(receivedToken.verifyWithPublicKey(nodeIdPublicKey)).toBeTrue(); - const sentTokenEncoded = receivedToken.payload.requestToken; - const sentToken = Token.fromEncoded(sentTokenEncoded); - expect(sentToken.verifyWithPublicKey(keyPair.publicKey)).toBeTrue(); - }); - - test.prop([fc.string()], { numRuns: 1 })( - 'should fail with an invalid JWT', - async (compactJWT) => { - // Use token to try and login - const command = ['auth', 'login', '-np', dataDir, compactJWT]; - const result = await testUtils.pkStdio(command, { - env: { PK_PASSWORD: password }, - cwd: dataDir, - }); - expect(result.exitCode).not.toBe(0); - - // We should never even get to a point where the fetch was invoked - const fetchMock = globalThis.fetch as jest.MockedFunction; - expect(fetchMock).not.toHaveBeenCalled(); - }, - ); - - test('should fail with incorrectly signed JWT', async () => { - // Generate and sign token with different key pairs - let keyPair = keysUtils.generateKeyPair(); - const publicKey = keyPair.publicKey.toString('base64url'); - keyPair = keysUtils.generateKeyPair(); - const returnURL = 'test'; - const token = Token.fromPayload({ - publicKey, - returnURL, - }); - token.signWithPrivateKey(keyPair.privateKey); - const encodedToken = utils.jsonToCompactJWT(token.toEncoded()); - - // Use token to try and login - const command = ['auth', 'login', '-np', dataDir, encodedToken]; - const result = await testUtils.pkStdio(command, { - env: { PK_PASSWORD: password }, - cwd: dataDir, - }); - expect(result.exitCode).not.toBe(0); - - // We should never even get to a point where the fetch was invoked - const fetchMock = globalThis.fetch as jest.MockedFunction; - expect(fetchMock).not.toHaveBeenCalled(); + expect(receivedToken.payload.exp).toBe(Math.floor(Date.now() / 1000)); + expect(receivedToken.payload.jti).toBeDefined(); }); }); diff --git a/tsconfig.json b/tsconfig.json index c98c4726..50ff259f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,7 @@ "allowSyntheticDefaultImports": true, "resolveJsonModule": true, "moduleResolution": "NodeNext", - "module": "ESNext", + "module": "NodeNext", "target": "ES2022", "baseUrl": "./src", "paths": {