0
Fork 0
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:
Xiao Yijun 2022-06-16 13:00:01 +08:00 committed by GitHub
parent 9fa5d8ca46
commit 8d27adce39
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 368 additions and 9 deletions

View file

@ -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;

View 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 */

View file

@ -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"
},

View file

@ -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) });

View 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`;

View 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);
}
}

View 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()}`;

View 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');
});
});

View file

@ -11,5 +11,5 @@
]
}
},
"include": ["tests", "src", "jest.*.ts"]
"include": ["tests", "src", "jest.*.ts", "jest.setup.js"]
}

View file

@ -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