From d425dbcb7f305a69d49880861c445dee09cbe4c8 Mon Sep 17 00:00:00 2001 From: Aryan Jassal Date: Wed, 18 Jun 2025 13:10:48 +1000 Subject: [PATCH 1/5] feat: added `polykey auth login` command --- src/auth/CommandAuth.ts | 13 +++++ src/auth/CommandLogin.ts | 109 +++++++++++++++++++++++++++++++++++++++ src/auth/index.ts | 1 + src/polykey.ts | 2 + 4 files changed, 125 insertions(+) create mode 100644 src/auth/CommandAuth.ts create mode 100644 src/auth/CommandLogin.ts create mode 100644 src/auth/index.ts diff --git a/src/auth/CommandAuth.ts b/src/auth/CommandAuth.ts new file mode 100644 index 00000000..40fe8e4e --- /dev/null +++ b/src/auth/CommandAuth.ts @@ -0,0 +1,13 @@ +import CommandLogin from './CommandLogin.js'; +import CommandPolykey from '../CommandPolykey.js'; + +class CommandAuth extends CommandPolykey { + constructor(...args: ConstructorParameters) { + super(...args); + this.name('auth'); + this.description('Authentication operations'); + this.addCommand(new CommandLogin(...args)); + } +} + +export default CommandAuth; diff --git a/src/auth/CommandLogin.ts b/src/auth/CommandLogin.ts new file mode 100644 index 00000000..d074b47d --- /dev/null +++ b/src/auth/CommandLogin.ts @@ -0,0 +1,109 @@ +import type PolykeyClient from 'polykey/PolykeyClient.js'; +import CommandPolykey from '../CommandPolykey.js'; +import * as binProcessors from '../utils/processors.js'; +import * as binUtils from '../utils/index.js'; +import * as binOptions from '../utils/options.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'); + this.addOption(binOptions.nodeId); + this.addOption(binOptions.clientHost); + this.addOption(binOptions.clientPort); + this.action(async (token, options) => { + const { default: PolykeyClient } = await import( + 'polykey/PolykeyClient.js' + ); + const { default: Token } = await import('polykey/tokens/Token.js'); + const keysUtils = await import('polykey/keys/utils/index.js'); + const tokensUtils = await import('polykey/tokens/utils.js'); + + const clientOptions = await binProcessors.processClientOptions( + options.nodePath, + options.nodeId, + options.clientHost, + options.clientPort, + this.fs, + this.logger.getChild(binProcessors.processClientOptions.name), + ); + const meta = await binProcessors.processAuthentication( + options.passwordFile, + this.fs, + ); + + let pkClient: PolykeyClient; + this.exitHandlers.handlers.push(async () => { + if (pkClient != null) await pkClient.stop(); + }); + try { + pkClient = await PolykeyClient.createPolykeyClient({ + nodeId: clientOptions.nodeId, + host: clientOptions.clientHost, + port: clientOptions.clientPort, + options: { + nodePath: options.nodePath, + }, + logger: this.logger.getChild(PolykeyClient.name), + }); + const keyPair = keysUtils.generateKeyPair(); + const inTok = Token.fromPayload({ + returnUrl: 'localhost:8000', + publicKey: keyPair.publicKey.toString('base64url'), + }); + inTok.signWithPrivateKey(keyPair); + console.log(`tok: ${inTok.toEncoded()}`); + // token = inTok.toEncoded(); + + // // 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 [protectedHeader, payload, signature] = token.split('.'); + // const tokenProtectedHeader = + // tokensUtils.parseTokenProtectedHeader(protectedHeader); + // const tokenPayload = tokensUtils.parseTokenPayload(payload); + // const tokenSignature = tokensUtils.parseTokenSignature(signature); + // const parsedToken = { + // payload: tokenPayload, + // signatures: [ + // { + // protected: tokenProtectedHeader, + // signature: tokenSignature, + // } + // ] + // }; + const parsedToken = inTok; + console.log(`parsed: ${JSON.stringify(parsedToken)}\n`); + // const incomingToken = Token.fromSigned(parsedToken); + // const tokenJson = incomingToken.toJSON(); + const response = await binUtils.retryAuthentication( + (auth) => + pkClient.rpcClient.methods.authSignToken({ + metadata: auth, + payload: inTok.toEncoded().payload, + signatures: inTok.toEncoded().signatures, + // signatures: [{protectees.protecHeaderteok.signature}], + }), + meta, + ); + const tokenOut = { + payload: response.payload, + signatures: response.signatures, + }; + console.log(`received: ${JSON.stringify(tokenOut)}\n`); + console.log(`payload: ${JSON.stringify(tokensUtils.parseTokenPayload(tokenOut.payload))}\n`); + console.log(`inc payload: ${JSON.stringify(tokensUtils.parseTokenPayload((tokensUtils.parseTokenPayload(tokenOut.payload).requestToken! as any).payload!))}\n`); + // await fetch(parsedToken.payload.returnUrl, { + // method: 'POST', + // body: JSON.stringify(tokenOut), + // }); + // console.log(`sent payload`); + } finally { + if (pkClient! != null) await pkClient.stop(); + } + }); + } +} + +export default CommandLogin; diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 00000000..48a0e02c --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1 @@ +export { default } from './CommandAuth.js'; diff --git a/src/polykey.ts b/src/polykey.ts index 8b19c378..19984c38 100755 --- a/src/polykey.ts +++ b/src/polykey.ts @@ -152,6 +152,7 @@ async function polykeyMain(argv: Array): Promise { const { default: CommandBootstrap } = await import('./bootstrap/index.js'); const { default: CommandAgent } = await import('./agent/index.js'); const { default: CommandAudit } = await import('./audit/index.js'); + const { default: CommandAuth } = await import('./auth/index.js'); const { default: CommandVaults } = await import('./vaults/index.js'); const { default: CommandSecrets } = await import('./secrets/index.js'); const { default: CommandKeys } = await import('./keys/index.js'); @@ -181,6 +182,7 @@ async function polykeyMain(argv: Array): Promise { rootCommand.addCommand(new CommandBootstrap({ exitHandlers, fs })); rootCommand.addCommand(new CommandAgent({ exitHandlers, fs })); rootCommand.addCommand(new CommandAudit({ exitHandlers, fs })); + rootCommand.addCommand(new CommandAuth({ exitHandlers, fs })); rootCommand.addCommand(new CommandNodes({ exitHandlers, fs })); rootCommand.addCommand(new CommandSecrets({ exitHandlers, fs })); rootCommand.addCommand(new CommandKeys({ exitHandlers, fs })); From 5782107f247e062843b847edba2eb60322040b14 Mon Sep 17 00:00:00 2001 From: Aryan Jassal Date: Wed, 18 Jun 2025 15:45:36 +1000 Subject: [PATCH 2/5] chore: cleaned up code --- src/auth/CommandLogin.ts | 94 ++++++++++++++++++++-------------------- src/errors.ts | 6 +++ 2 files changed, 53 insertions(+), 47 deletions(-) diff --git a/src/auth/CommandLogin.ts b/src/auth/CommandLogin.ts index d074b47d..ffea248b 100644 --- a/src/auth/CommandLogin.ts +++ b/src/auth/CommandLogin.ts @@ -1,8 +1,15 @@ 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 binUtils from '../utils/index.js'; import * as binOptions from '../utils/options.js'; +import * as binErrors from '../errors.js'; class CommandLogin extends CommandPolykey { constructor(...args: ConstructorParameters) { @@ -17,10 +24,7 @@ class CommandLogin extends CommandPolykey { const { default: PolykeyClient } = await import( 'polykey/PolykeyClient.js' ); - const { default: Token } = await import('polykey/tokens/Token.js'); - const keysUtils = await import('polykey/keys/utils/index.js'); const tokensUtils = await import('polykey/tokens/utils.js'); - const clientOptions = await binProcessors.processClientOptions( options.nodePath, options.nodeId, @@ -48,57 +52,53 @@ class CommandLogin extends CommandPolykey { }, logger: this.logger.getChild(PolykeyClient.name), }); - const keyPair = keysUtils.generateKeyPair(); - const inTok = Token.fromPayload({ - returnUrl: 'localhost:8000', - publicKey: keyPair.publicKey.toString('base64url'), - }); - inTok.signWithPrivateKey(keyPair); - console.log(`tok: ${inTok.toEncoded()}`); - // token = inTok.toEncoded(); - - // // 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 [protectedHeader, payload, signature] = token.split('.'); - // const tokenProtectedHeader = - // tokensUtils.parseTokenProtectedHeader(protectedHeader); - // const tokenPayload = tokensUtils.parseTokenPayload(payload); - // const tokenSignature = tokensUtils.parseTokenSignature(signature); - // const parsedToken = { - // payload: tokenPayload, - // signatures: [ - // { - // protected: tokenProtectedHeader, - // signature: tokenSignature, - // } - // ] - // }; - const parsedToken = inTok; - console.log(`parsed: ${JSON.stringify(parsedToken)}\n`); - // const incomingToken = Token.fromSigned(parsedToken); - // const tokenJson = incomingToken.toJSON(); + // 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 [protectedHeader, payload, signature]: [string, string, string] = + token.split('.'); + const incomingTokenEncoded = { + payload: payload as TokenPayloadEncoded, + signatures: [ + { + protected: protectedHeader as TokenProtectedHeaderEncoded, + signature: signature as TokenSignatureEncoded, + }, + ], + }; const response = await binUtils.retryAuthentication( (auth) => pkClient.rpcClient.methods.authSignToken({ metadata: auth, - payload: inTok.toEncoded().payload, - signatures: inTok.toEncoded().signatures, - // signatures: [{protectees.protecHeaderteok.signature}], + ...incomingTokenEncoded, }), meta, ); - const tokenOut = { - payload: response.payload, - signatures: response.signatures, - }; - console.log(`received: ${JSON.stringify(tokenOut)}\n`); - console.log(`payload: ${JSON.stringify(tokensUtils.parseTokenPayload(tokenOut.payload))}\n`); - console.log(`inc payload: ${JSON.stringify(tokensUtils.parseTokenPayload((tokensUtils.parseTokenPayload(tokenOut.payload).requestToken! as any).payload!))}\n`); - // await fetch(parsedToken.payload.returnUrl, { - // method: 'POST', - // body: JSON.stringify(tokenOut), - // }); - // console.log(`sent payload`); + // We don't expect multiple signatures so a compact JWT will suffice + const compactHeader = `${response.signatures[0].protected}.${response.payload}.${response.signatures[0].signature}`; + const incomingPayload = tokensUtils.parseTokenPayload(payload); + let result: Response; + try { + result = await fetch(incomingPayload.returnUrl, { + method: 'POST', + body: JSON.stringify({ token: compactHeader }), + }); + } catch (e) { + throw new binErrors.ErrorPolykeyCLILoginFailed( + 'Failed to send token to return url', + { cause: e, }, + ); + } + // Handle non-200 response + if (!result.ok) { + throw new binErrors.ErrorPolykeyCLILoginFailed( + 'Return url returned failure', + { + data: { + code: result.status, + }, + }, + ); + } } finally { if (pkClient! != null) await pkClient.stop(); } diff --git a/src/errors.ts b/src/errors.ts index 75744c18..59f483b3 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -196,6 +196,11 @@ class ErrorPolykeyCLITouchSecret extends ErrorPolykeyCLI { exitCode = 1; } +class ErrorPolykeyCLILoginFailed extends ErrorPolykeyCLI { + static description = 'Failed to login using Polykey'; + exitCode = sysexits.SOFTWARE; +} + export { ErrorPolykeyCLI, ErrorPolykeyCLIUncaughtException, @@ -224,4 +229,5 @@ export { ErrorPolykeyCLICatSecret, ErrorPolykeyCLIEditSecret, ErrorPolykeyCLITouchSecret, + ErrorPolykeyCLILoginFailed, }; From 8158f0c567792903a076a7d7b232cd53a57e766e Mon Sep 17 00:00:00 2001 From: Aryan Jassal Date: Wed, 18 Jun 2025 19:13:22 +1000 Subject: [PATCH 3/5] fix: added proper parsing for incoming token --- src/auth/CommandLogin.ts | 44 ++++++++++++++++++++++------------------ src/errors.ts | 6 ++++++ src/utils/parsers.ts | 26 ++++++++++++++++++++++++ src/utils/utils.ts | 16 ++++++++++++--- 4 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/auth/CommandLogin.ts b/src/auth/CommandLogin.ts index ffea248b..9203c118 100644 --- a/src/auth/CommandLogin.ts +++ b/src/auth/CommandLogin.ts @@ -7,20 +7,25 @@ import type { 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 binErrors from '../errors.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'); + this.argument( + '', + 'Token provided by platform for logging in', + binParsers.parseCompactJWT, + ); this.addOption(binOptions.nodeId); this.addOption(binOptions.clientHost); this.addOption(binOptions.clientPort); - this.action(async (token, options) => { + this.action(async (encodedToken, options) => { const { default: PolykeyClient } = await import( 'polykey/PolykeyClient.js' ); @@ -52,10 +57,9 @@ class CommandLogin extends CommandPolykey { }, logger: this.logger.getChild(PolykeyClient.name), }); - // 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 [protectedHeader, payload, signature]: [string, string, string] = - token.split('.'); + + // Create a JSON representation of the encoded header + const [protectedHeader, payload, signature] = encodedToken; const incomingTokenEncoded = { payload: payload as TokenPayloadEncoded, signatures: [ @@ -65,6 +69,8 @@ class CommandLogin extends CommandPolykey { }, ], }; + + // Get it verified and signed by the agent const response = await binUtils.retryAuthentication( (auth) => pkClient.rpcClient.methods.authSignToken({ @@ -73,30 +79,28 @@ class CommandLogin extends CommandPolykey { }), meta, ); - // We don't expect multiple signatures so a compact JWT will suffice - const compactHeader = `${response.signatures[0].protected}.${response.payload}.${response.signatures[0].signature}`; - const incomingPayload = tokensUtils.parseTokenPayload(payload); + + // 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; try { - result = await fetch(incomingPayload.returnUrl, { + result = await fetch(incomingPayload.returnURL, { method: 'POST', body: JSON.stringify({ token: compactHeader }), }); } catch (e) { - throw new binErrors.ErrorPolykeyCLILoginFailed( + throw new errors.ErrorPolykeyCLILoginFailed( 'Failed to send token to return url', - { cause: e, }, + { cause: e }, ); } + // Handle non-200 response if (!result.ok) { - throw new binErrors.ErrorPolykeyCLILoginFailed( - 'Return url returned failure', - { - data: { - code: result.status, - }, - }, + throw new errors.ErrorPolykeyCLILoginFailed( + `Return url returned failure with code ${result.status}`, ); } } finally { diff --git a/src/errors.ts b/src/errors.ts index 59f483b3..469715ee 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -196,6 +196,11 @@ class ErrorPolykeyCLITouchSecret extends ErrorPolykeyCLI { exitCode = 1; } +class ErrorPolykeyCLIInvalidJWT extends ErrorPolykeyCLI { + static description: 'JWT is not valid'; + exitCode = sysexits.USAGE; +} + class ErrorPolykeyCLILoginFailed extends ErrorPolykeyCLI { static description = 'Failed to login using Polykey'; exitCode = sysexits.SOFTWARE; @@ -229,5 +234,6 @@ export { ErrorPolykeyCLICatSecret, ErrorPolykeyCLIEditSecret, ErrorPolykeyCLITouchSecret, + ErrorPolykeyCLIInvalidJWT, ErrorPolykeyCLILoginFailed, }; diff --git a/src/utils/parsers.ts b/src/utils/parsers.ts index 39eec37a..43eff651 100644 --- a/src/utils/parsers.ts +++ b/src/utils/parsers.ts @@ -13,6 +13,7 @@ const vaultNameRegex = /^(?!.*[:])[ -~\t\n]*$/s; const secretPathRegex = /^(?!.*[=])[ -~\t\n]*$/s; const secretPathValueRegex = /^([a-zA-Z_][\w]+)?$/; const environmentVariableRegex = /^([a-zA-Z_]+[a-zA-Z0-9_]*)?$/; +const base64UrlRegex = /^[A-Za-z0-9\-_]+$/; /** * Converts a validation parser to commander argument parser @@ -192,6 +193,30 @@ 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, @@ -220,4 +245,5 @@ export { parseProviderId, parseIdentityId, parseProviderIdList, + parseCompactJWT, }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 55e8dba8..2ba3433d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,5 @@ -import type { FileSystem } from 'polykey/types.js'; -import type { POJO } from 'polykey/types.js'; +import type { FileSystem, POJO } from 'polykey/types.js'; +import type { SignedTokenEncoded } from 'polykey/tokens/types.js'; import type { TableRow, TableOptions, @@ -469,7 +469,7 @@ function outputFormatterError(err: any): string { if (err.data && !utils.isEmptyObject(err.data)) { output += `${indent}data\t${JSON.stringify(err.data)}\n`; } - if (err.cause) { + if (err.cause && !utils.isEmptyObject(err.cause)) { output += `${indent}cause: `; if (err.cause instanceof ErrorPolykey) { err = err.cause; @@ -637,6 +637,15 @@ async function importFS(fs?: FileSystem): Promise { return fsImported; } +function jsonToCompactJWT(token: SignedTokenEncoded): string { + if (token.signatures.length !== 1) { + throw new errors.ErrorPolykeyCLIInvalidJWT( + 'Too many signatures, expected 1', + ); + } + return `${token.signatures[0].protected}.${token.payload}.${token.signatures[0].signature}`; +} + export { verboseToLogLevel, standardErrorReplacer, @@ -660,6 +669,7 @@ export { generateVersionString, promise, importFS, + jsonToCompactJWT, }; export type { OutputObject }; From f4b2c8b5bbbed6665ac44492f9a4a811934e986f Mon Sep 17 00:00:00 2001 From: Aryan Jassal Date: Thu, 19 Jun 2025 10:28:08 +1000 Subject: [PATCH 4/5] deps: updated polykey from 2.3.5 to 2.4.0 fix: error rendering --- npmDepsHash | 2 +- package-lock.json | 8 ++++---- package.json | 2 +- src/utils/utils.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/npmDepsHash b/npmDepsHash index 8e42835b..30ccd5bd 100644 --- a/npmDepsHash +++ b/npmDepsHash @@ -1 +1 @@ -sha256-ckDhoiFXCuB/abIn/GSYKFHMIwe+7pTHCoTbc3Hsyuo= +sha256-QZafJAHalDT2QQhw4evfJ1fun7saN4Mz08JX8YP4SDQ= diff --git a/package-lock.json b/package-lock.json index e25db967..33299311 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,7 +38,7 @@ "mocked-env": "^1.3.5", "nexpect": "^0.6.0", "node-gyp-build": "^4.8.4", - "polykey": "^2.3.5", + "polykey": "^2.4.0", "shelljs": "^0.8.5", "shx": "^0.3.4", "tsx": "^3.12.7", @@ -9561,9 +9561,9 @@ } }, "node_modules/polykey": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/polykey/-/polykey-2.3.5.tgz", - "integrity": "sha512-+UZTxufcnvi8+JRM+/C5AbM779ztzpbLzh61tBUDfvdygGGjVZzGcHtHyRofQsy3z2Lkf0hINbRhGszodvxcBA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/polykey/-/polykey-2.4.0.tgz", + "integrity": "sha512-IWYTjcGns8FV736TLixnaqCip983ZjHbO5cD3MNPxpK6OwTVh/1s3bIP9JJMSsBFld4IAJ+qp2geGNz7eXMQ9g==", "dev": true, "dependencies": { "@matrixai/async-cancellable": "^2.0.1", diff --git a/package.json b/package.json index 59dd6650..b44533a4 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "mocked-env": "^1.3.5", "nexpect": "^0.6.0", "node-gyp-build": "^4.8.4", - "polykey": "^2.3.5", + "polykey": "^2.4.0", "shelljs": "^0.8.5", "shx": "^0.3.4", "tsx": "^3.12.7", diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 2ba3433d..882a2f93 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -469,7 +469,7 @@ function outputFormatterError(err: any): string { if (err.data && !utils.isEmptyObject(err.data)) { output += `${indent}data\t${JSON.stringify(err.data)}\n`; } - if (err.cause && !utils.isEmptyObject(err.cause)) { + if (err.cause) { output += `${indent}cause: `; if (err.cause instanceof ErrorPolykey) { err = err.cause; From be4d1773c849e6ebb84b7da136ef980531969839 Mon Sep 17 00:00:00 2001 From: Aryan Jassal Date: Thu, 19 Jun 2025 13:27:11 +1000 Subject: [PATCH 5/5] test: added tests for auth login command fix: restore mocks properly after testing fix: restoring mocked function properly --- tests/auth/login.test.ts | 162 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 tests/auth/login.test.ts diff --git a/tests/auth/login.test.ts b/tests/auth/login.test.ts new file mode 100644 index 00000000..d119cb79 --- /dev/null +++ b/tests/auth/login.test.ts @@ -0,0 +1,162 @@ +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 fs from 'node:fs'; +import fc from 'fast-check'; +import { jest } from '@jest/globals'; +import { test } from '@fast-check/jest'; +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'; + const logger = new Logger('CLI Test', LogLevel.WARN, [new StreamHandler()]); + let dataDir: string; + 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' }, + }), + ), + ); + dataDir = await fs.promises.mkdtemp( + path.join(globalThis.tmpDir, 'polykey-test-'), + ); + polykeyAgent = await PolykeyAgent.createPolykeyAgent({ + password, + options: { + nodePath: dataDir, + agentServiceHost: '127.0.0.1', + clientServiceHost: '127.0.0.1', + keys: { + passwordOpsLimit: keysUtils.passwordOpsLimits.min, + passwordMemLimit: keysUtils.passwordMemLimits.min, + strictMemoryLock: false, + }, + }, + logger: logger, + }); + }); + afterEach(async () => { + jest.restoreAllMocks(); + await polykeyAgent.stop(); + await fs.promises.rm(dataDir, { + force: true, + recursive: true, + }); + }); + + 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]; + 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'); + + // Reconstruct the token + expect(typeof options!.body).toBe('string'); + const responseBody: { token: string } = JSON.parse( + options!.body! as string, + ); + const receivedEncodedToken = responseBody.token; + const [header, payload, signature] = receivedEncodedToken.split('.'); + const receivedToken = Token.fromEncoded({ + payload: payload as TokenPayloadEncoded, + signatures: [ + { + protected: header as TokenProtectedHeaderEncoded, + signature: signature as TokenSignatureEncoded, + }, + ], + }); + + // Verify the incoming token. The nodeId is the node's public key. + const nodeId = nodesUtils.decodeNodeId(receivedToken.payload.nodeId); + 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(); + }); +});