diff --git a/packages/integration-tests/jest.config.ts b/packages/integration-tests/jest.config.ts index 6b49c6296..e397c3752 100644 --- a/packages/integration-tests/jest.config.ts +++ b/packages/integration-tests/jest.config.ts @@ -1,7 +1,8 @@ import { merge, Config } from '@silverhand/jest-config'; const config: Config.InitialOptions = merge({ - testEnvironment: 'node', + testEnvironment: 'jsdom', + setupFilesAfterEnv: ['/jest.setup.js'], }); export default config; diff --git a/packages/integration-tests/jest.setup.js b/packages/integration-tests/jest.setup.js new file mode 100644 index 000000000..3e107884d --- /dev/null +++ b/packages/integration-tests/jest.setup.js @@ -0,0 +1,14 @@ +// Need to disable following rules to mock text-decode/text-encoder and crypto for jsdom +// https://github.com/jsdom/jsdom/issues/1612 +import { Crypto } from '@peculiar/webcrypto'; +import { TextDecoder, TextEncoder } from 'text-encoder'; + +// eslint-disable-next-line unicorn/prefer-module +const fetch = require('node-fetch'); + +/* eslint-disable @silverhand/fp/no-mutation */ +global.crypto = new Crypto(); +global.fetch = fetch; +global.TextDecoder = TextDecoder; +global.TextEncoder = TextEncoder; +/* eslint-enable @silverhand/fp/no-mutation */ diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index 26c1bc594..225a56978 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -12,6 +12,9 @@ "start": "jest" }, "devDependencies": { + "@jest/types": "^27.5.1", + "@logto/js": "^0.1.16", + "@peculiar/webcrypto": "^1.3.3", "@silverhand/eslint-config": "^0.14.0", "@silverhand/essentials": "^1.1.7", "@silverhand/jest-config": "^0.14.0", @@ -21,7 +24,9 @@ "eslint": "^8.10.0", "got": "^11.8.2", "jest": "^27.5.1", + "node-fetch": "^2.6.7", "prettier": "^2.3.2", + "text-encoder": "^0.0.4", "ts-node": "^10.0.0", "typescript": "^4.6.4" }, diff --git a/packages/integration-tests/src/api.ts b/packages/integration-tests/src/api.ts index 50b4609d9..ac84b0700 100644 --- a/packages/integration-tests/src/api.ts +++ b/packages/integration-tests/src/api.ts @@ -1,4 +1,5 @@ -import { getEnv } from '@silverhand/essentials'; import got from 'got'; -export default got.extend({ prefixUrl: new URL('/api', getEnv('LOGTO_URL')) }); +import { logtoUrl } from '@/constants'; + +export default got.extend({ prefixUrl: new URL('/api', logtoUrl) }); diff --git a/packages/integration-tests/src/constants.ts b/packages/integration-tests/src/constants.ts new file mode 100644 index 000000000..e9a5f743f --- /dev/null +++ b/packages/integration-tests/src/constants.ts @@ -0,0 +1,9 @@ +import { getEnv } from '@silverhand/essentials'; + +export const logtoUrl = getEnv('LOGTO_URL'); + +export const adminConsoleApplicationId = 'admin_console'; + +export const discoveryUrl = `${logtoUrl}/oidc/.well-known/openid-configuration`; + +export const redirectUri = `${logtoUrl}/console/callback`; diff --git a/packages/integration-tests/src/logto-context.ts b/packages/integration-tests/src/logto-context.ts new file mode 100644 index 000000000..cd19e8feb --- /dev/null +++ b/packages/integration-tests/src/logto-context.ts @@ -0,0 +1,111 @@ +import { generateCodeVerifier, generateState, generateCodeChallenge } from '@logto/js'; + +import { generatePassword, generateUsername } from './utils'; + +type Account = { + username: string; + password: string; +}; + +type ContextData = { + account: Account; + codeVerifier: string; + codeChallenge: string; + state: string; + authorizationEndpoint: string; + tokenEndpoint: string; + authorizationCode: string; + interactionCookie: string; + nextRedirectTo: string; +}; + +type ContextDataKey = keyof ContextData; + +type ContextStore = { + getData: (key: T) => ContextData[T]; + setData: (key: T, value: ContextData[T]) => void; +}; + +const createContextStore = (): ContextStore => { + const data: ContextData = { + account: { username: '', password: '' }, + codeVerifier: '', + codeChallenge: '', + state: '', + interactionCookie: '', + authorizationCode: '', + authorizationEndpoint: '', + tokenEndpoint: '', + nextRedirectTo: '', + }; + + return { + getData: (key: T) => data[key], + setData: (key: T, value: ContextData[T]) => { + // eslint-disable-next-line @silverhand/fp/no-mutation + data[key] = value; + }, + }; +}; + +export class LogtoContext { + private readonly contextData: ContextStore = createContextStore(); + + public async init() { + const account = { + username: generatePassword(), + password: generateUsername(), + }; + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + + this.setData('account', account); + this.setData('codeVerifier', codeVerifier); + this.setData('codeChallenge', codeChallenge); + this.setData('state', generateState()); + } + + public get account(): Account { + return this.getData('account'); + } + + public get codeVerifier(): string { + return this.getData('codeVerifier'); + } + + public get codeChallenge(): string { + return this.getData('codeChallenge'); + } + + public get state(): string { + return this.getData('state'); + } + + public get authorizationCode(): string { + return this.getData('authorizationCode'); + } + + public get interactionCookie(): string { + return this.getData('interactionCookie'); + } + + public get authorizationEndpoint(): string { + return this.getData('authorizationEndpoint'); + } + + public get tokenEndpoint(): string { + return this.getData('tokenEndpoint'); + } + + public get nextRedirectTo(): string { + return this.getData('nextRedirectTo'); + } + + public setData(key: T, value: ContextData[T]): void { + this.contextData.setData(key, value); + } + + private getData(key: T): ContextData[T] { + return this.contextData.getData(key); + } +} diff --git a/packages/integration-tests/src/utils.ts b/packages/integration-tests/src/utils.ts new file mode 100644 index 000000000..1d074b0b2 --- /dev/null +++ b/packages/integration-tests/src/utils.ts @@ -0,0 +1,10 @@ +import { Response } from 'got/dist/source'; + +export const extractCookie = (response: Response) => { + const { headers } = response; + + return headers['set-cookie']?.join('; ') ?? ''; +}; + +export const generateUsername = () => `usr-${crypto.randomUUID()}`; +export const generatePassword = () => `pwd-${crypto.randomUUID()}`; diff --git a/packages/integration-tests/tests/username-password-flow.test.ts b/packages/integration-tests/tests/username-password-flow.test.ts new file mode 100644 index 000000000..d2aa67df5 --- /dev/null +++ b/packages/integration-tests/tests/username-password-flow.test.ts @@ -0,0 +1,183 @@ +import { + createRequester, + fetchOidcConfig, + fetchTokenByAuthorizationCode, + generateSignInUri, + verifyAndParseCodeFromCallbackUri, +} from '@logto/js'; +import got from 'got/dist/source'; + +import api from '@/api'; + +import { adminConsoleApplicationId, discoveryUrl, logtoUrl, redirectUri } from '../src/constants'; +import { LogtoContext } from '../src/logto-context'; +import { extractCookie } from '../src/utils'; + +describe('username and password flow', () => { + const logtoContext = new LogtoContext(); + + beforeAll(async () => { + await logtoContext.init(); + }); + + it('should fetch OIDC configuration', async () => { + const oidcConfig = await fetchOidcConfig(discoveryUrl, createRequester()); + const { authorizationEndpoint, tokenEndpoint } = oidcConfig; + expect(authorizationEndpoint).toBeTruthy(); + expect(tokenEndpoint).toBeTruthy(); + + logtoContext.setData('authorizationEndpoint', authorizationEndpoint); + logtoContext.setData('tokenEndpoint', tokenEndpoint); + }); + + it('should visit authorization endpoint and get interaction cookie', async () => { + const signInUri = generateSignInUri({ + authorizationEndpoint: logtoContext.authorizationEndpoint, + clientId: adminConsoleApplicationId, + redirectUri, + codeChallenge: logtoContext.codeChallenge, + state: logtoContext.state, + }); + + const response = await got(signInUri, { + followRedirect: false, + }); + + // Note: this will redirect to the ui sign in page + expect(response.statusCode).toBe(303); + expect(response.headers.location).toBe('/sign-in'); + + const cookie = extractCookie(response); + expect(cookie).toBeTruthy(); + + logtoContext.setData('interactionCookie', cookie); + }); + + it('should register with username and password and redirect to oidc/auth endpoint to start an auth process', async () => { + type RegisterResponse = { + redirectTo: string; + }; + + const registerResponse = await api + .post('session/register/username-password', { + headers: { + cookie: logtoContext.interactionCookie, + }, + json: logtoContext.account, + }) + .json(); + + const { redirectTo: invokeAuthUrl } = registerResponse; + + expect(invokeAuthUrl.startsWith(`${logtoUrl}/oidc/auth`)).toBeTruthy(); + }); + + it('should sign in with username and password and redirect to oidc/auth endpoint to start an auth process', async () => { + type SignInResponse = { + redirectTo: string; + }; + + const signInResponse = await api + .post('session/sign-in/username-password', { + headers: { + cookie: logtoContext.interactionCookie, + }, + json: logtoContext.account, + followRedirect: false, + }) + .json(); + + const { redirectTo: invokeAuthUrl } = signInResponse; + + expect(invokeAuthUrl.startsWith(`${logtoUrl}/oidc/auth`)).toBeTruthy(); + + logtoContext.setData('nextRedirectTo', invokeAuthUrl); + }); + + it('should invoke the auth process and redirect to the consent page with session cookie', async () => { + const invokeAuthUrl = logtoContext.nextRedirectTo; + const invokeAuthResponse = await got.get(invokeAuthUrl, { + headers: { + cookie: logtoContext.interactionCookie, + }, + followRedirect: false, + }); + + // Note: Redirect to consent page + expect(invokeAuthResponse).toHaveProperty('statusCode', 303); + expect(invokeAuthResponse.headers.location).toBe('/sign-in/consent'); + + const cookie = extractCookie(invokeAuthResponse); + expect(cookie).toBeTruthy(); + expect(cookie.includes('_session.sig')).toBeTruthy(); + + logtoContext.setData('interactionCookie', cookie); + }); + + it('should redirect to oidc/auth endpoint to complete the auth process after consent', async () => { + type ConsentResponse = { + redirectTo: string; + }; + + const consentResponse = await api + .post('session/consent', { + headers: { + cookie: logtoContext.interactionCookie, + }, + followRedirect: false, + }) + .json(); + + const { redirectTo: completeAuthUrl } = consentResponse; + + expect(completeAuthUrl.startsWith(`${logtoUrl}/oidc/auth`)).toBeTruthy(); + + logtoContext.setData('nextRedirectTo', completeAuthUrl); + }); + + it('should get the authorization code from the callback uri when the auth process is completed', async () => { + const completeAuthUrl = logtoContext.nextRedirectTo; + const authCodeResponse = await got.get(completeAuthUrl, { + headers: { + cookie: logtoContext.interactionCookie, + }, + }); + + expect(authCodeResponse).toHaveProperty('statusCode', 200); + const callbackUri = authCodeResponse.redirectUrls[0]; + expect(callbackUri).toBeTruthy(); + + if (!callbackUri) { + throw new Error('No redirect uri'); + } + + const authorizationCode = verifyAndParseCodeFromCallbackUri( + callbackUri, + redirectUri, + logtoContext.state + ); + expect(authorizationCode).toBeTruthy(); + + logtoContext.setData('authorizationCode', authorizationCode); + }); + + it('should fetch token by authorization code', async () => { + const token = await fetchTokenByAuthorizationCode( + { + clientId: adminConsoleApplicationId, + tokenEndpoint: logtoContext.tokenEndpoint, + redirectUri, + codeVerifier: logtoContext.codeVerifier, + code: logtoContext.authorizationCode, + }, + createRequester() + ); + + expect(token).toHaveProperty('accessToken'); + expect(token).toHaveProperty('expiresIn'); + expect(token).toHaveProperty('idToken'); + expect(token).toHaveProperty('refreshToken'); + expect(token).toHaveProperty('scope'); + expect(token).toHaveProperty('tokenType'); + }); +}); diff --git a/packages/integration-tests/tsconfig.json b/packages/integration-tests/tsconfig.json index 885c502c5..d978a7287 100644 --- a/packages/integration-tests/tsconfig.json +++ b/packages/integration-tests/tsconfig.json @@ -11,5 +11,5 @@ ] } }, - "include": ["tests", "src", "jest.*.ts"] + "include": ["tests", "src", "jest.*.ts", "jest.setup.js"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7379406ac..f7fece0a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -962,6 +962,9 @@ importers: packages/integration-tests: specifiers: + '@jest/types': ^27.5.1 + '@logto/js': ^0.1.16 + '@peculiar/webcrypto': ^1.3.3 '@silverhand/eslint-config': ^0.14.0 '@silverhand/essentials': ^1.1.7 '@silverhand/jest-config': ^0.14.0 @@ -971,10 +974,15 @@ importers: eslint: ^8.10.0 got: ^11.8.2 jest: ^27.5.1 + node-fetch: ^2.6.7 prettier: ^2.3.2 + text-encoder: ^0.0.4 ts-node: ^10.0.0 typescript: ^4.6.4 devDependencies: + '@jest/types': 27.5.1 + '@logto/js': 0.1.16 + '@peculiar/webcrypto': 1.3.3 '@silverhand/eslint-config': 0.14.0_rqoong6vegs374egqglqjbgiwm '@silverhand/essentials': 1.1.7 '@silverhand/jest-config': 0.14.0_53ggqi2i4rbcfjtktmjua6zili @@ -984,7 +992,9 @@ importers: eslint: 8.10.0 got: 11.8.3 jest: 27.5.1_ts-node@10.7.0 + node-fetch: 2.6.7 prettier: 2.5.1 + text-encoder: 0.0.4 ts-node: 10.7.0_drbbnc2wk7uwp4gsdsdvgzqgya typescript: 4.6.4 @@ -2811,6 +2821,17 @@ packages: superstruct: 0.15.4 dev: true + /@logto/js/0.1.16: + resolution: {integrity: sha512-SwOmfQn/QJ6OTchElYSP5hKoXKm9sVOWmcgwjlblPUutWAMyR2Eo4wMBiiwjHQSNoXvYmK06cOXZp9BK4iYsrw==} + dependencies: + '@silverhand/essentials': 1.1.7 + camelcase-keys: 7.0.2 + jose: 4.6.0 + js-base64: 3.7.2 + lodash.get: 4.4.2 + superstruct: 0.15.4 + dev: true + /@logto/react/0.1.15_react@17.0.2: resolution: {integrity: sha512-GCbVRooMdCOBWJLvfDpChkKYt96Hr/Ki03xjO/gDb5KKQ3zrK9fSvN36vtUidRpddbWUsJJCK40J+FzCwUsHGg==} requiresBuild: true @@ -7546,7 +7567,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.14.0_pzezdwkd5bvjkx2hshexc25sxq + '@typescript-eslint/parser': 5.14.0_fo4uz55zgcu432252zy2gvpvcu debug: 3.2.7 eslint-import-resolver-node: 0.3.6 eslint-import-resolver-typescript: 2.5.0_rnagsyfcubvpoxo2ynj23pim7u @@ -7597,7 +7618,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.14.0_pzezdwkd5bvjkx2hshexc25sxq + '@typescript-eslint/parser': 5.14.0_fo4uz55zgcu432252zy2gvpvcu array-includes: 3.1.4 array.prototype.flat: 1.2.5 debug: 2.6.9 @@ -14682,6 +14703,10 @@ packages: glob: 7.2.0 minimatch: 3.1.2 + /text-encoder/0.0.4: + resolution: {integrity: sha512-12gllbNnC0Zdh9r+LCpEwpUdvncaE9hfUmCVm2ryCH1LEVUZbS6NdRq8omEgJI0zKgaGFTjwQVHbglGDCIbmNA==} + dev: true + /text-extensions/1.9.0: resolution: {integrity: sha512-wiBrwC1EhBelW12Zy26JeOUkQ5mRu+5o8rpsJk5+2t+Y5vE7e842qtZDQ2g1NpX/29HdyFeJ4nSIhI47ENSxlQ==} engines: {node: '>=0.10'} @@ -14752,7 +14777,7 @@ packages: universalify: 0.1.2 /tr46/0.0.3: - resolution: {integrity: sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=} + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} /tr46/1.0.1: resolution: {integrity: sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=} @@ -15558,7 +15583,7 @@ packages: dev: true /webidl-conversions/3.0.1: - resolution: {integrity: sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=} + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} /webidl-conversions/4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -15581,7 +15606,7 @@ packages: resolution: {integrity: sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==} /whatwg-url/5.0.0: - resolution: {integrity: sha1-lmRU6HZUYuN2RNNib2dCzotwll0=} + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1