mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
test(integration): username and password flow (#1111)
This commit is contained in:
parent
9fa5d8ca46
commit
8d27adce39
10 changed files with 368 additions and 9 deletions
|
@ -1,7 +1,8 @@
|
|||
import { merge, Config } from '@silverhand/jest-config';
|
||||
|
||||
const config: Config.InitialOptions = merge({
|
||||
testEnvironment: 'node',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
|
14
packages/integration-tests/jest.setup.js
Normal file
14
packages/integration-tests/jest.setup.js
Normal file
|
@ -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 */
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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) });
|
||||
|
|
9
packages/integration-tests/src/constants.ts
Normal file
9
packages/integration-tests/src/constants.ts
Normal file
|
@ -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`;
|
111
packages/integration-tests/src/logto-context.ts
Normal file
111
packages/integration-tests/src/logto-context.ts
Normal file
|
@ -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: <T extends ContextDataKey>(key: T) => ContextData[T];
|
||||
setData: <T extends ContextDataKey>(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: <T extends ContextDataKey>(key: T) => data[key],
|
||||
setData: <T extends ContextDataKey>(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<T extends ContextDataKey>(key: T, value: ContextData[T]): void {
|
||||
this.contextData.setData(key, value);
|
||||
}
|
||||
|
||||
private getData<T extends ContextDataKey>(key: T): ContextData[T] {
|
||||
return this.contextData.getData(key);
|
||||
}
|
||||
}
|
10
packages/integration-tests/src/utils.ts
Normal file
10
packages/integration-tests/src/utils.ts
Normal file
|
@ -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()}`;
|
183
packages/integration-tests/tests/username-password-flow.test.ts
Normal file
183
packages/integration-tests/tests/username-password-flow.test.ts
Normal file
|
@ -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<RegisterResponse>();
|
||||
|
||||
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<SignInResponse>();
|
||||
|
||||
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<ConsentResponse>();
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
|
@ -11,5 +11,5 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"include": ["tests", "src", "jest.*.ts"]
|
||||
"include": ["tests", "src", "jest.*.ts", "jest.setup.js"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue