mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor(core): remove session api (#2878)
This commit is contained in:
parent
8b8103132e
commit
39f15acb40
26 changed files with 2 additions and 4971 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -1,3 +1,2 @@
|
||||||
/packages/schemas/tables @simeng-li @wangsijie
|
/packages/schemas/tables @simeng-li @wangsijie
|
||||||
/packages/core/src/routes/session @simeng-li @wangsijie
|
|
||||||
/.changeset @gao-sun
|
/.changeset @gao-sun
|
||||||
|
|
|
@ -1,155 +0,0 @@
|
||||||
import { LogResult } from '@logto/schemas';
|
|
||||||
import type { LogPayload } from '@logto/schemas/lib/types/log-legacy.js';
|
|
||||||
import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
|
||||||
import i18next from 'i18next';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
import type { WithLogContextLegacy } from './koa-audit-log-legacy.js';
|
|
||||||
|
|
||||||
const { jest } = import.meta;
|
|
||||||
|
|
||||||
const { mockEsm } = createMockUtils(jest);
|
|
||||||
|
|
||||||
const nanoIdMock = 'mockId';
|
|
||||||
|
|
||||||
const addLogContext = jest.fn();
|
|
||||||
const log = jest.fn();
|
|
||||||
|
|
||||||
const { insertLog } = mockEsm('#src/queries/log.js', () => ({
|
|
||||||
insertLog: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
mockEsm('nanoid', () => ({
|
|
||||||
nanoid: () => nanoIdMock,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const koaLog = await pickDefault(import('./koa-audit-log-legacy.js'));
|
|
||||||
|
|
||||||
describe('koaLog middleware', () => {
|
|
||||||
const type = 'SignInUsernamePassword';
|
|
||||||
const mockPayload: LogPayload = {
|
|
||||||
userId: 'foo',
|
|
||||||
username: 'Bar',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ip = '192.168.0.1';
|
|
||||||
const userAgent =
|
|
||||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should insert a success log when next() does not throw an error', async () => {
|
|
||||||
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
|
|
||||||
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
|
|
||||||
// Bypass middleware context type assert
|
|
||||||
addLogContext,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
ctx.request.ip = ip;
|
|
||||||
const additionalMockPayload: LogPayload = { foo: 'bar' };
|
|
||||||
|
|
||||||
const next = async () => {
|
|
||||||
ctx.log(type, mockPayload);
|
|
||||||
ctx.log(type, additionalMockPayload);
|
|
||||||
};
|
|
||||||
await koaLog()(ctx, next);
|
|
||||||
|
|
||||||
expect(insertLog).toBeCalledWith({
|
|
||||||
id: nanoIdMock,
|
|
||||||
key: type,
|
|
||||||
payload: {
|
|
||||||
...mockPayload,
|
|
||||||
...additionalMockPayload,
|
|
||||||
result: LogResult.Success,
|
|
||||||
ip,
|
|
||||||
userAgent,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not insert a log when there is no log type', async () => {
|
|
||||||
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
|
|
||||||
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
|
|
||||||
// Bypass middleware context type assert
|
|
||||||
addLogContext,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
ctx.request.ip = ip;
|
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/consistent-function-scoping, @typescript-eslint/no-empty-function
|
|
||||||
const next = async () => {};
|
|
||||||
await koaLog()(ctx, next);
|
|
||||||
expect(insertLog).not.toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('should insert an error log with the error message when next() throws an error', () => {
|
|
||||||
it('should log with error message when next throws a normal Error', async () => {
|
|
||||||
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
|
|
||||||
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
|
|
||||||
// Bypass middleware context type assert
|
|
||||||
addLogContext,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
ctx.request.ip = ip;
|
|
||||||
|
|
||||||
const message = 'Normal error';
|
|
||||||
const error = new Error(message);
|
|
||||||
|
|
||||||
const next = async () => {
|
|
||||||
ctx.log(type, mockPayload);
|
|
||||||
throw error;
|
|
||||||
};
|
|
||||||
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
|
|
||||||
|
|
||||||
expect(insertLog).toBeCalledWith({
|
|
||||||
id: nanoIdMock,
|
|
||||||
key: type,
|
|
||||||
payload: {
|
|
||||||
...mockPayload,
|
|
||||||
result: LogResult.Error,
|
|
||||||
error: { message: `Error: ${message}` },
|
|
||||||
ip,
|
|
||||||
userAgent,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should insert an error log with the error body when next() throws a RequestError', async () => {
|
|
||||||
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
|
|
||||||
...createContextWithRouteParameters({ headers: { 'user-agent': userAgent } }),
|
|
||||||
// Bypass middleware context type assert
|
|
||||||
addLogContext,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
ctx.request.ip = ip;
|
|
||||||
|
|
||||||
const message = 'Error message';
|
|
||||||
jest.spyOn(i18next, 't').mockReturnValueOnce(message); // Used in
|
|
||||||
const code = 'connector.general';
|
|
||||||
const data = { foo: 'bar', num: 123 };
|
|
||||||
const error = new RequestError(code, data);
|
|
||||||
|
|
||||||
const next = async () => {
|
|
||||||
ctx.log(type, mockPayload);
|
|
||||||
throw error;
|
|
||||||
};
|
|
||||||
await expect(koaLog()(ctx, next)).rejects.toMatchError(error);
|
|
||||||
|
|
||||||
expect(insertLog).toBeCalledWith({
|
|
||||||
id: nanoIdMock,
|
|
||||||
key: type,
|
|
||||||
payload: {
|
|
||||||
...mockPayload,
|
|
||||||
result: LogResult.Error,
|
|
||||||
error: { message, code, data },
|
|
||||||
ip,
|
|
||||||
userAgent,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,118 +0,0 @@
|
||||||
import { LogResult } from '@logto/schemas';
|
|
||||||
import type {
|
|
||||||
BaseLogPayload,
|
|
||||||
LogPayload,
|
|
||||||
LogPayloads,
|
|
||||||
LogType,
|
|
||||||
} from '@logto/schemas/lib/types/log-legacy.js';
|
|
||||||
import { pick } from '@silverhand/essentials';
|
|
||||||
import deepmerge from 'deepmerge';
|
|
||||||
import type { MiddlewareType } from 'koa';
|
|
||||||
import type { IRouterParamContext } from 'koa-router';
|
|
||||||
import { nanoid } from 'nanoid';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { insertLog } from '#src/queries/log.js';
|
|
||||||
|
|
||||||
type MergeLog = <T extends LogType>(type: T, payload: LogPayloads[T]) => void;
|
|
||||||
|
|
||||||
type SessionPayload = {
|
|
||||||
sessionId?: string;
|
|
||||||
applicationId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AddLogContext = (sessionPayload: SessionPayload) => void;
|
|
||||||
|
|
||||||
/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */
|
|
||||||
export type LogContextLegacy = {
|
|
||||||
addLogContext: AddLogContext;
|
|
||||||
log: MergeLog;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */
|
|
||||||
export type WithLogContextLegacy<ContextT extends IRouterParamContext = IRouterParamContext> =
|
|
||||||
ContextT & LogContextLegacy;
|
|
||||||
|
|
||||||
type Logger = {
|
|
||||||
type?: LogType;
|
|
||||||
basePayload?: BaseLogPayload;
|
|
||||||
payload: LogPayload;
|
|
||||||
set: (basePayload: BaseLogPayload) => void;
|
|
||||||
log: MergeLog;
|
|
||||||
save: () => Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* eslint-disable @silverhand/fp/no-mutation */
|
|
||||||
const initLogger = (basePayload?: Readonly<BaseLogPayload>) => {
|
|
||||||
const logger: Logger = {
|
|
||||||
type: undefined,
|
|
||||||
basePayload,
|
|
||||||
payload: {},
|
|
||||||
set: (basePayload) => {
|
|
||||||
logger.basePayload = {
|
|
||||||
...logger.basePayload,
|
|
||||||
...basePayload,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
log: (type, payload) => {
|
|
||||||
if (type !== logger.type) {
|
|
||||||
logger.type = type;
|
|
||||||
logger.payload = payload;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.payload = deepmerge(logger.payload, payload);
|
|
||||||
},
|
|
||||||
save: async () => {
|
|
||||||
if (!logger.type) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await insertLog({
|
|
||||||
id: nanoid(),
|
|
||||||
key: logger.type,
|
|
||||||
payload: {
|
|
||||||
...logger.basePayload,
|
|
||||||
...logger.payload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return logger;
|
|
||||||
};
|
|
||||||
/* eslint-enable @silverhand/fp/no-mutation */
|
|
||||||
|
|
||||||
/** @deprecated This will be removed soon. Use `kua-audit-log.js` instead. */
|
|
||||||
export default function koaAuditLogLegacy<
|
|
||||||
StateT,
|
|
||||||
ContextT extends IRouterParamContext,
|
|
||||||
ResponseBodyT
|
|
||||||
>(): MiddlewareType<StateT, WithLogContextLegacy<ContextT>, ResponseBodyT> {
|
|
||||||
return async (ctx, next) => {
|
|
||||||
const {
|
|
||||||
ip,
|
|
||||||
headers: { 'user-agent': userAgent },
|
|
||||||
} = ctx.request;
|
|
||||||
|
|
||||||
const logger = initLogger({ result: LogResult.Success, ip, userAgent });
|
|
||||||
ctx.addLogContext = logger.set;
|
|
||||||
ctx.log = logger.log;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await next();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
logger.set({
|
|
||||||
result: LogResult.Error,
|
|
||||||
error:
|
|
||||||
error instanceof RequestError
|
|
||||||
? pick(error, 'message', 'code', 'data')
|
|
||||||
: { message: String(error) },
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await logger.save();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,77 +0,0 @@
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js';
|
|
||||||
import koaLogSessionLegacy from '#src/middleware/koa-log-session-legacy.js';
|
|
||||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
const { jest } = import.meta;
|
|
||||||
|
|
||||||
const provider = new Provider('https://logto.test');
|
|
||||||
const interactionDetails = jest.spyOn(provider, 'interactionDetails');
|
|
||||||
|
|
||||||
describe('koaLogSessionLegacy', () => {
|
|
||||||
const sessionId = 'sessionId';
|
|
||||||
const applicationId = 'applicationId';
|
|
||||||
const addLogContext = jest.fn();
|
|
||||||
const log = jest.fn();
|
|
||||||
const next = jest.fn();
|
|
||||||
|
|
||||||
// @ts-expect-error for testing
|
|
||||||
interactionDetails.mockResolvedValue({
|
|
||||||
jti: sessionId,
|
|
||||||
params: {
|
|
||||||
client_id: applicationId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get session info from the provider', async () => {
|
|
||||||
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
|
|
||||||
...createContextWithRouteParameters(),
|
|
||||||
addLogContext,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow();
|
|
||||||
expect(interactionDetails).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log session id and application id', async () => {
|
|
||||||
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
|
|
||||||
...createContextWithRouteParameters(),
|
|
||||||
addLogContext,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow();
|
|
||||||
expect(addLogContext).toHaveBeenCalledWith({ sessionId, applicationId });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call next', async () => {
|
|
||||||
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
|
|
||||||
...createContextWithRouteParameters(),
|
|
||||||
addLogContext,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow();
|
|
||||||
expect(next).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not throw when interactionDetails throw error', async () => {
|
|
||||||
const ctx: WithLogContextLegacy<ReturnType<typeof createContextWithRouteParameters>> = {
|
|
||||||
...createContextWithRouteParameters(),
|
|
||||||
addLogContext,
|
|
||||||
log,
|
|
||||||
};
|
|
||||||
|
|
||||||
interactionDetails.mockImplementationOnce(() => {
|
|
||||||
throw new Error('message');
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(koaLogSessionLegacy(provider)(ctx, next)).resolves.not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,25 +0,0 @@
|
||||||
import type { MiddlewareType } from 'koa';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js';
|
|
||||||
|
|
||||||
/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */
|
|
||||||
export default function koaLogSessionLegacy<
|
|
||||||
StateT,
|
|
||||||
ContextT extends WithLogContextLegacy,
|
|
||||||
ResponseBodyT
|
|
||||||
>(provider: Provider): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
|
||||||
return async (ctx, next) => {
|
|
||||||
await next();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
jti,
|
|
||||||
params: { client_id },
|
|
||||||
} = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
ctx.addLogContext({ sessionId: jti, applicationId: String(client_id) });
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error(`Failed to get oidc provider interaction`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -2,11 +2,9 @@ import { UserRole } from '@logto/schemas';
|
||||||
import Koa from 'koa';
|
import Koa from 'koa';
|
||||||
import Router from 'koa-router';
|
import Router from 'koa-router';
|
||||||
|
|
||||||
import koaAuditLogLegacy from '#src/middleware/koa-audit-log-legacy.js';
|
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
|
||||||
import koaAuth from '../middleware/koa-auth.js';
|
import koaAuth from '../middleware/koa-auth.js';
|
||||||
import koaLogSessionLegacy from '../middleware/koa-log-session-legacy.js';
|
|
||||||
import adminUserRoleRoutes from './admin-user-role.js';
|
import adminUserRoleRoutes from './admin-user-role.js';
|
||||||
import adminUserRoutes from './admin-user.js';
|
import adminUserRoutes from './admin-user.js';
|
||||||
import applicationRoutes from './application.js';
|
import applicationRoutes from './application.js';
|
||||||
|
@ -21,19 +19,14 @@ import phraseRoutes from './phrase.js';
|
||||||
import profileRoutes from './profile.js';
|
import profileRoutes from './profile.js';
|
||||||
import resourceRoutes from './resource.js';
|
import resourceRoutes from './resource.js';
|
||||||
import roleRoutes from './role.js';
|
import roleRoutes from './role.js';
|
||||||
import sessionRoutes from './session/index.js';
|
|
||||||
import settingRoutes from './setting.js';
|
import settingRoutes from './setting.js';
|
||||||
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
||||||
import statusRoutes from './status.js';
|
import statusRoutes from './status.js';
|
||||||
import swaggerRoutes from './swagger.js';
|
import swaggerRoutes from './swagger.js';
|
||||||
import type { AnonymousRouter, AnonymousRouterLegacy, AuthedRouter } from './types.js';
|
import type { AnonymousRouter, AuthedRouter } from './types.js';
|
||||||
import wellKnownRoutes from './well-known.js';
|
import wellKnownRoutes from './well-known.js';
|
||||||
|
|
||||||
const createRouters = (tenant: TenantContext) => {
|
const createRouters = (tenant: TenantContext) => {
|
||||||
const sessionRouter: AnonymousRouterLegacy = new Router();
|
|
||||||
sessionRouter.use(koaAuditLogLegacy(), koaLogSessionLegacy(tenant.provider));
|
|
||||||
sessionRoutes(sessionRouter, tenant);
|
|
||||||
|
|
||||||
const interactionRouter: AnonymousRouter = new Router();
|
const interactionRouter: AnonymousRouter = new Router();
|
||||||
interactionRoutes(interactionRouter, tenant);
|
interactionRoutes(interactionRouter, tenant);
|
||||||
|
|
||||||
|
@ -62,21 +55,19 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
authnRoutes(anonymousRouter, tenant);
|
authnRoutes(anonymousRouter, tenant);
|
||||||
// The swagger.json should contain all API routers.
|
// The swagger.json should contain all API routers.
|
||||||
swaggerRoutes(anonymousRouter, [
|
swaggerRoutes(anonymousRouter, [
|
||||||
sessionRouter,
|
|
||||||
interactionRouter,
|
interactionRouter,
|
||||||
profileRouter,
|
profileRouter,
|
||||||
managementRouter,
|
managementRouter,
|
||||||
anonymousRouter,
|
anonymousRouter,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [sessionRouter, interactionRouter, profileRouter, managementRouter, anonymousRouter];
|
return [interactionRouter, profileRouter, managementRouter, anonymousRouter];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function initRouter(tenant: TenantContext): Koa {
|
export default function initRouter(tenant: TenantContext): Koa {
|
||||||
const apisApp = new Koa();
|
const apisApp = new Koa();
|
||||||
|
|
||||||
for (const router of createRouters(tenant)) {
|
for (const router of createRouters(tenant)) {
|
||||||
// @ts-expect-error will remove once interaction refactor finished
|
|
||||||
apisApp.use(router.routes()).use(router.allowedMethods());
|
apisApp.use(router.routes()).use(router.allowedMethods());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,233 +0,0 @@
|
||||||
import { VerificationCodeType } from '@logto/connector-kit';
|
|
||||||
import { addDays, subSeconds } from 'date-fns';
|
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import { mockUser } from '#src/__mocks__/index.js';
|
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
import continueRoutes, { continueRoute } from './continue.js';
|
|
||||||
|
|
||||||
const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString();
|
|
||||||
const getVerificationStorageFromInteraction = jest.fn();
|
|
||||||
|
|
||||||
const checkRequiredProfile = jest.fn();
|
|
||||||
jest.mock('./utils', () => ({
|
|
||||||
...jest.requireActual('./utils'),
|
|
||||||
checkRequiredProfile: () => checkRequiredProfile(),
|
|
||||||
getVerificationStorageFromInteraction: () => getVerificationStorageFromInteraction(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/sign-in-experience.js', () => ({
|
|
||||||
findDefaultSignInExperience: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const findUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const hasUser = jest.fn();
|
|
||||||
const hasUserWithPhone = jest.fn();
|
|
||||||
const hasUserWithEmail = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('#src/queries/user.js', () => ({
|
|
||||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
findUserById: async () => findUserById(),
|
|
||||||
hasUser: async () => hasUser(),
|
|
||||||
hasUserWithPhone: async () => hasUserWithPhone(),
|
|
||||||
hasUserWithEmail: async () => hasUserWithEmail(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
|
||||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
|
||||||
|
|
||||||
jest.mock('oidc-provider', () => ({
|
|
||||||
Provider: jest.fn(() => ({
|
|
||||||
interactionDetails,
|
|
||||||
interactionResult,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
interactionResult.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('session -> continueRoutes', () => {
|
|
||||||
const sessionRequest = createRequester({
|
|
||||||
// @ts-expect-error will remove once interaction refactor finished
|
|
||||||
anonymousRoutes: continueRoutes,
|
|
||||||
provider: new Provider(''),
|
|
||||||
middlewares: [
|
|
||||||
async (ctx, next) => {
|
|
||||||
ctx.addLogContext = jest.fn();
|
|
||||||
ctx.log = jest.fn();
|
|
||||||
|
|
||||||
return next();
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/continue/password', () => {
|
|
||||||
it('updates user password, checks required profile, and sign in', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
result: {
|
|
||||||
continueSignIn: {
|
|
||||||
userId: mockUser.id,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
findUserById.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
passwordEncrypted: null,
|
|
||||||
identities: {},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${continueRoute}/password`).send({
|
|
||||||
password: 'password',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(checkRequiredProfile).toHaveBeenCalled();
|
|
||||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything());
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/continue/username', () => {
|
|
||||||
it('updates user username, checks required profile, and sign in', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
result: {
|
|
||||||
continueSignIn: {
|
|
||||||
userId: mockUser.id,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
findUserById.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
username: null,
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${continueRoute}/username`).send({
|
|
||||||
username: 'username',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(checkRequiredProfile).toHaveBeenCalled();
|
|
||||||
expect(hasUser).toHaveBeenCalled();
|
|
||||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything());
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/continue/email', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
getVerificationStorageFromInteraction.mockResolvedValueOnce({ email: 'email' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates user email, checks required profile, and sign in', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
result: {
|
|
||||||
continueSignIn: {
|
|
||||||
userId: mockUser.id,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
type: VerificationCodeType.Continue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
findUserById.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
primaryEmail: null,
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${continueRoute}/email`).send();
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(checkRequiredProfile).toHaveBeenCalled();
|
|
||||||
expect(hasUser).toHaveBeenCalled();
|
|
||||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything());
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/continue/sms', () => {
|
|
||||||
it('updates user phone, checks required profile, and sign in', async () => {
|
|
||||||
getVerificationStorageFromInteraction.mockResolvedValueOnce({ phone: 'phone' });
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
result: {
|
|
||||||
continueSignIn: {
|
|
||||||
userId: mockUser.id,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
type: VerificationCodeType.Continue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
findUserById.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
primaryPhone: null,
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${continueRoute}/sms`).send();
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(checkRequiredProfile).toHaveBeenCalled();
|
|
||||||
expect(hasUserWithPhone).toHaveBeenCalled();
|
|
||||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, expect.anything());
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('general invalid cases', () => {
|
|
||||||
test.each(['password', 'username', 'email', 'sms'])(
|
|
||||||
'throws on empty continue sign in storage',
|
|
||||||
async (route) => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
result: {},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${continueRoute}/${route}`).send({
|
|
||||||
password: 'password',
|
|
||||||
username: 'username',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
test.each(['password', 'username', 'email', 'sms'])(
|
|
||||||
'throws on expired continue sign in storage',
|
|
||||||
async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
result: {
|
|
||||||
continueSignIn: {
|
|
||||||
userId: mockUser.id,
|
|
||||||
expiresAt: subSeconds(Date.now(), 1).toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${continueRoute}/password`).send({
|
|
||||||
password: 'password',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,184 +0,0 @@
|
||||||
import { passwordRegEx, usernameRegEx } from '@logto/core-kit';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
import { object, string } from 'zod';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import {
|
|
||||||
assignInteractionResults,
|
|
||||||
getApplicationIdFromInteraction,
|
|
||||||
} from '#src/libraries/session.js';
|
|
||||||
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
|
||||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
|
||||||
import {
|
|
||||||
findUserById,
|
|
||||||
hasUser,
|
|
||||||
hasUserWithEmail,
|
|
||||||
hasUserWithPhone,
|
|
||||||
updateUserById,
|
|
||||||
} from '#src/queries/user.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
|
|
||||||
import type { AnonymousRouterLegacy } from '../types.js';
|
|
||||||
import { continueEmailSessionResultGuard, continueSmsSessionResultGuard } from './types.js';
|
|
||||||
import {
|
|
||||||
checkRequiredProfile,
|
|
||||||
getContinueSignInResult,
|
|
||||||
getRoutePrefix,
|
|
||||||
getVerificationStorageFromInteraction,
|
|
||||||
isUserPasswordSet,
|
|
||||||
} from './utils.js';
|
|
||||||
|
|
||||||
export const continueRoute = getRoutePrefix('sign-in', 'continue');
|
|
||||||
|
|
||||||
export default function continueRoutes<T extends AnonymousRouterLegacy>(
|
|
||||||
router: T,
|
|
||||||
provider: Provider
|
|
||||||
) {
|
|
||||||
router.post(
|
|
||||||
`${continueRoute}/password`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
password: string().regex(passwordRegEx),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { password } = ctx.guard.body;
|
|
||||||
const { userId } = await getContinueSignInResult(ctx, provider);
|
|
||||||
const user = await findUserById(userId);
|
|
||||||
|
|
||||||
// Social identities can take place the role of password
|
|
||||||
assertThat(
|
|
||||||
!isUserPasswordSet(user),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.password_exists_in_profile',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
|
||||||
const updatedUser = await updateUserById(userId, {
|
|
||||||
passwordEncrypted,
|
|
||||||
passwordEncryptionMethod,
|
|
||||||
});
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
`${continueRoute}/username`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
username: string().regex(usernameRegEx),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { username } = ctx.guard.body;
|
|
||||||
const { userId } = await getContinueSignInResult(ctx, provider);
|
|
||||||
const user = await findUserById(userId);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!user.username,
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.username_exists_in_profile',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!(await hasUser(username)),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.username_already_in_use',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedUser = await updateUserById(userId, {
|
|
||||||
username,
|
|
||||||
});
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(`${continueRoute}/email`, async (ctx, next) => {
|
|
||||||
const { userId } = await getContinueSignInResult(ctx, provider);
|
|
||||||
const { email } = await getVerificationStorageFromInteraction(
|
|
||||||
ctx,
|
|
||||||
provider,
|
|
||||||
continueEmailSessionResultGuard
|
|
||||||
);
|
|
||||||
const user = await findUserById(userId);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!user.primaryEmail,
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.email_exists_in_profile',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!(await hasUserWithEmail(email)),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.email_already_in_use',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedUser = await updateUserById(userId, {
|
|
||||||
primaryEmail: email,
|
|
||||||
});
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post(`${continueRoute}/sms`, async (ctx, next) => {
|
|
||||||
const { userId } = await getContinueSignInResult(ctx, provider);
|
|
||||||
const { phone } = await getVerificationStorageFromInteraction(
|
|
||||||
ctx,
|
|
||||||
provider,
|
|
||||||
continueSmsSessionResultGuard
|
|
||||||
);
|
|
||||||
const user = await findUserById(userId);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!user.primaryPhone,
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.phone_exists_in_profile',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!(await hasUserWithPhone(phone)),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.phone_already_in_use',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedUser = await updateUserById(userId, {
|
|
||||||
primaryPhone: phone,
|
|
||||||
});
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
await checkRequiredProfile(ctx, provider, updatedUser, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: updatedUser.id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,236 +0,0 @@
|
||||||
import { VerificationCodeType } from '@logto/connector-kit';
|
|
||||||
import type { User } from '@logto/schemas';
|
|
||||||
import { addDays, subDays } from 'date-fns';
|
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import {
|
|
||||||
mockPasswordEncrypted,
|
|
||||||
mockSignInExperience,
|
|
||||||
mockUserWithPassword,
|
|
||||||
} from '#src/__mocks__/index.js';
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
import forgotPasswordRoutes, { forgotPasswordRoute } from './forgot-password.js';
|
|
||||||
|
|
||||||
const encryptUserPassword = jest.fn(async (password: string) => ({
|
|
||||||
passwordEncrypted: password + '_user1',
|
|
||||||
passwordEncryptionMethod: 'Argon2i',
|
|
||||||
}));
|
|
||||||
const findUserById = jest.fn(async (): Promise<User> => mockUserWithPassword);
|
|
||||||
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ userId: 'id' }));
|
|
||||||
const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience);
|
|
||||||
const getYesterdayDate = () => subDays(Date.now(), 1);
|
|
||||||
const getTomorrowDate = () => addDays(Date.now(), 1);
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/user.js', () => ({
|
|
||||||
...jest.requireActual('#src/libraries/user.js'),
|
|
||||||
encryptUserPassword: async (password: string) => encryptUserPassword(password),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/user.js', () => ({
|
|
||||||
...jest.requireActual('#src/queries/user.js'),
|
|
||||||
hasUserWithPhone: async (phone: string) => phone === '13000000000',
|
|
||||||
findUserByPhone: async () => ({ userId: 'id' }),
|
|
||||||
hasUserWithEmail: async (email: string) => email === 'a@a.com',
|
|
||||||
findUserByEmail: async () => ({ userId: 'id' }),
|
|
||||||
findUserById: async () => findUserById(),
|
|
||||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/sign-in-experience.js', () => ({
|
|
||||||
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
|
|
||||||
jest.mock('#src/libraries/passcode.js', () => ({
|
|
||||||
createPasscode: async () => ({ userId: 'id' }),
|
|
||||||
sendPasscode: async () => sendPasscode(),
|
|
||||||
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
|
|
||||||
if (code !== '1234') {
|
|
||||||
throw new RequestError('verification_code.code_mismatch');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockArgon2Verify = jest.fn(async (password: string) => password === mockPasswordEncrypted);
|
|
||||||
jest.mock('hash-wasm', () => ({
|
|
||||||
argon2Verify: async (password: string) => mockArgon2Verify(password),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
|
||||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
|
||||||
|
|
||||||
jest.mock('oidc-provider', () => ({
|
|
||||||
Provider: jest.fn(() => ({
|
|
||||||
interactionDetails,
|
|
||||||
interactionResult,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
interactionResult.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('session -> forgotPasswordRoutes', () => {
|
|
||||||
const sessionRequest = createRequester({
|
|
||||||
// @ts-expect-error will remove once interaction refactor finished
|
|
||||||
anonymousRoutes: forgotPasswordRoutes,
|
|
||||||
provider: new Provider(''),
|
|
||||||
middlewares: [
|
|
||||||
async (ctx, next) => {
|
|
||||||
ctx.addLogContext = jest.fn();
|
|
||||||
ctx.log = jest.fn();
|
|
||||||
|
|
||||||
return next();
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/forgot-password/reset', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
it('assign result and redirect', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
userId: 'id',
|
|
||||||
expiresAt: getTomorrowDate().toISOString(),
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${forgotPasswordRoute}/reset`)
|
|
||||||
.send({ password: mockPasswordEncrypted });
|
|
||||||
expect(updateUserById).toBeCalledWith(
|
|
||||||
'id',
|
|
||||||
expect.objectContaining({
|
|
||||||
passwordEncrypted: 'a1b2c3_user1',
|
|
||||||
passwordEncryptionMethod: 'Argon2i',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
});
|
|
||||||
it('should throw when `id` is missing', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
expiresAt: getTomorrowDate().toISOString(),
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${forgotPasswordRoute}/reset`)
|
|
||||||
.send({ password: mockPasswordEncrypted });
|
|
||||||
expect(response).toHaveProperty('status', 404);
|
|
||||||
expect(updateUserById).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
it('should throw when flow is not `forgot-password`', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
userId: 'id',
|
|
||||||
expiresAt: getTomorrowDate().toISOString(),
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${forgotPasswordRoute}/reset`)
|
|
||||||
.send({ password: mockPasswordEncrypted });
|
|
||||||
expect(response).toHaveProperty('status', 404);
|
|
||||||
expect(updateUserById).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
it('should throw when `verification.expiresAt` is not string', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: { userId: 'id', expiresAt: 0, flow: VerificationCodeType.ForgotPassword },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${forgotPasswordRoute}/reset`)
|
|
||||||
.send({ password: mockPasswordEncrypted });
|
|
||||||
expect(response).toHaveProperty('status', 404);
|
|
||||||
expect(updateUserById).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
it('should throw when `expiresAt` is not a valid date string', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
userId: 'id',
|
|
||||||
expiresAt: 'invalid date string',
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${forgotPasswordRoute}/reset`)
|
|
||||||
.send({ password: mockPasswordEncrypted });
|
|
||||||
expect(response).toHaveProperty('status', 401);
|
|
||||||
expect(updateUserById).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
it('should throw when verification expires', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
userId: 'id',
|
|
||||||
expiresAt: getYesterdayDate().toISOString(),
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${forgotPasswordRoute}/reset`)
|
|
||||||
.send({ password: mockPasswordEncrypted });
|
|
||||||
expect(response).toHaveProperty('status', 401);
|
|
||||||
expect(updateUserById).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
it('should throw when new password is the same as old one', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
userId: 'id',
|
|
||||||
expiresAt: getTomorrowDate().toISOString(),
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
mockArgon2Verify.mockResolvedValueOnce(true);
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${forgotPasswordRoute}/reset`)
|
|
||||||
.send({ password: mockPasswordEncrypted });
|
|
||||||
expect(response).toHaveProperty('status', 422);
|
|
||||||
expect(updateUserById).toBeCalledTimes(0);
|
|
||||||
});
|
|
||||||
it('should redirect when there was no old password', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
userId: 'id',
|
|
||||||
expiresAt: getTomorrowDate().toISOString(),
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
findUserById.mockResolvedValueOnce({
|
|
||||||
...mockUserWithPassword,
|
|
||||||
passwordEncrypted: null,
|
|
||||||
passwordEncryptionMethod: null,
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${forgotPasswordRoute}/reset`)
|
|
||||||
.send({ password: mockPasswordEncrypted });
|
|
||||||
expect(updateUserById).toBeCalledWith(
|
|
||||||
'id',
|
|
||||||
expect.objectContaining({
|
|
||||||
passwordEncrypted: 'a1b2c3_user1',
|
|
||||||
passwordEncryptionMethod: 'Argon2i',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,64 +0,0 @@
|
||||||
import { passwordRegEx } from '@logto/core-kit';
|
|
||||||
import { argon2Verify } from 'hash-wasm';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { encryptUserPassword } from '#src/libraries/user.js';
|
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
|
||||||
import { findUserById, updateUserById } from '#src/queries/user.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
|
|
||||||
import type { AnonymousRouterLegacy } from '../types.js';
|
|
||||||
import { forgotPasswordSessionResultGuard } from './types.js';
|
|
||||||
import {
|
|
||||||
clearVerificationResult,
|
|
||||||
getRoutePrefix,
|
|
||||||
getVerificationStorageFromInteraction,
|
|
||||||
checkValidateExpiration,
|
|
||||||
} from './utils.js';
|
|
||||||
|
|
||||||
export const forgotPasswordRoute = getRoutePrefix('forgot-password');
|
|
||||||
|
|
||||||
export default function forgotPasswordRoutes<T extends AnonymousRouterLegacy>(
|
|
||||||
router: T,
|
|
||||||
provider: Provider
|
|
||||||
) {
|
|
||||||
router.post(
|
|
||||||
`${forgotPasswordRoute}/reset`,
|
|
||||||
koaGuard({ body: z.object({ password: z.string().regex(passwordRegEx) }) }),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { password } = ctx.guard.body;
|
|
||||||
|
|
||||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
|
||||||
ctx,
|
|
||||||
provider,
|
|
||||||
forgotPasswordSessionResultGuard
|
|
||||||
);
|
|
||||||
|
|
||||||
const type = 'ForgotPasswordReset';
|
|
||||||
ctx.log(type, verificationStorage);
|
|
||||||
|
|
||||||
const { userId, expiresAt } = verificationStorage;
|
|
||||||
|
|
||||||
checkValidateExpiration(expiresAt);
|
|
||||||
|
|
||||||
const { passwordEncrypted: oldPasswordEncrypted } = await findUserById(userId);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!oldPasswordEncrypted || !(await argon2Verify({ password, hash: oldPasswordEncrypted })),
|
|
||||||
new RequestError({ code: 'user.same_password', status: 422 })
|
|
||||||
);
|
|
||||||
|
|
||||||
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
|
||||||
|
|
||||||
ctx.log(type, { userId });
|
|
||||||
|
|
||||||
await updateUserById(userId, { passwordEncrypted, passwordEncryptionMethod });
|
|
||||||
await clearVerificationResult(ctx, provider);
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,226 +0,0 @@
|
||||||
import type { User } from '@logto/schemas';
|
|
||||||
import { adminConsoleApplicationId } from '@logto/schemas';
|
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import { mockUser } from '#src/__mocks__/index.js';
|
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
import sessionRoutes from './index.js';
|
|
||||||
|
|
||||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
|
||||||
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
|
||||||
|
|
||||||
const grantSave = jest.fn(async () => 'finalGrantId');
|
|
||||||
const grantAddOIDCScope = jest.fn();
|
|
||||||
const grantAddResourceScope = jest.fn();
|
|
||||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
|
||||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/user.js', () => ({
|
|
||||||
findUserById: async () => findUserById(),
|
|
||||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
class Grant {
|
|
||||||
static async find(id: string) {
|
|
||||||
return id === 'exists' ? new Grant() : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
save: typeof grantSave;
|
|
||||||
addOIDCScope: typeof grantAddOIDCScope;
|
|
||||||
addResourceScope: typeof grantAddResourceScope;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.save = grantSave;
|
|
||||||
this.addOIDCScope = grantAddOIDCScope;
|
|
||||||
this.addResourceScope = grantAddResourceScope;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock('oidc-provider', () => ({
|
|
||||||
Provider: jest.fn(() => ({
|
|
||||||
Grant,
|
|
||||||
interactionDetails,
|
|
||||||
interactionResult,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
grantSave.mockClear();
|
|
||||||
interactionResult.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('sessionRoutes', () => {
|
|
||||||
const sessionRequest = createRequester({
|
|
||||||
// @ts-expect-error will remove once interaction refactor finished
|
|
||||||
anonymousRoutes: sessionRoutes,
|
|
||||||
provider: new Provider(''),
|
|
||||||
middlewares: [
|
|
||||||
async (ctx, next) => {
|
|
||||||
ctx.addLogContext = jest.fn();
|
|
||||||
ctx.log = jest.fn();
|
|
||||||
|
|
||||||
return next();
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session', () => {
|
|
||||||
it('should redirect to /session/consent with consent prompt name', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
prompt: { name: 'consent' },
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post('/session');
|
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(response.body).toHaveProperty(
|
|
||||||
'redirectTo',
|
|
||||||
expect.stringContaining('/session/consent')
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error with other prompt name', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
prompt: { name: 'invalid' },
|
|
||||||
});
|
|
||||||
await expect(sessionRequest.post('/session').send({})).resolves.toHaveProperty('status', 400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/consent', () => {
|
|
||||||
describe('should call grant.save() and assign interaction results', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
updateUserById.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('with empty details and reusing old grant', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
session: { accountId: 'accountId' },
|
|
||||||
params: { client_id: 'clientId' },
|
|
||||||
prompt: { details: {} },
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post('/session/consent');
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(grantSave).toHaveBeenCalled();
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
consent: { grantId: 'finalGrantId' },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('with empty details and creating new grant', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
session: { accountId: 'accountId' },
|
|
||||||
params: { client_id: 'clientId' },
|
|
||||||
prompt: { details: {} },
|
|
||||||
grantId: 'exists',
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post('/session/consent');
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(grantSave).toHaveBeenCalled();
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
consent: { grantId: 'finalGrantId' },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should save application id when the user first consented', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
session: { accountId: mockUser.id },
|
|
||||||
params: { client_id: 'clientId' },
|
|
||||||
prompt: {
|
|
||||||
name: 'consent',
|
|
||||||
details: {},
|
|
||||||
reasons: ['consent_prompt', 'native_client_prompt'],
|
|
||||||
},
|
|
||||||
grantId: 'grantId',
|
|
||||||
});
|
|
||||||
|
|
||||||
findUserById.mockImplementationOnce(async () => ({ ...mockUser, applicationId: null }));
|
|
||||||
|
|
||||||
const response = await sessionRequest.post('/session/consent');
|
|
||||||
|
|
||||||
expect(updateUserById).toHaveBeenCalledWith(mockUser.id, { applicationId: 'clientId' });
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('missingOIDCScope and missingResourceScopes', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
session: { accountId: 'accountId' },
|
|
||||||
params: { client_id: 'clientId' },
|
|
||||||
prompt: {
|
|
||||||
details: {
|
|
||||||
missingOIDCScope: ['scope1', 'scope2'],
|
|
||||||
missingResourceScopes: {
|
|
||||||
resource1: ['scope1', 'scope2'],
|
|
||||||
resource2: ['scope3'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post('/session/consent');
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(grantAddOIDCScope).toHaveBeenCalledWith('scope1 scope2');
|
|
||||||
expect(grantAddResourceScope).toHaveBeenCalledWith('resource1', 'scope1 scope2');
|
|
||||||
expect(grantAddResourceScope).toHaveBeenCalledWith('resource2', 'scope3');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
consent: { grantId: 'finalGrantId' },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw is non-admin user request for AC consent', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
session: { accountId: mockUser.id },
|
|
||||||
params: { client_id: adminConsoleApplicationId },
|
|
||||||
prompt: {
|
|
||||||
name: 'consent',
|
|
||||||
details: {},
|
|
||||||
reasons: ['consent_prompt', 'native_client_prompt'],
|
|
||||||
},
|
|
||||||
grantId: 'grantId',
|
|
||||||
});
|
|
||||||
|
|
||||||
findUserById.mockImplementationOnce(async () => ({
|
|
||||||
...mockUser,
|
|
||||||
roleNames: [],
|
|
||||||
applicationId: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const response = await sessionRequest.post('/session/consent');
|
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if session is missing', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: { client_id: 'clientId' } });
|
|
||||||
await expect(sessionRequest.post('/session/consent')).resolves.toHaveProperty(
|
|
||||||
'statusCode',
|
|
||||||
400
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('DELETE /session', async () => {
|
|
||||||
const response = await sessionRequest.delete('/session');
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ error: 'oidc.aborted' }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,108 +0,0 @@
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
import type { LogtoErrorCode } from '@logto/phrases';
|
|
||||||
import { UserRole, adminConsoleApplicationId } from '@logto/schemas';
|
|
||||||
import { conditional } from '@silverhand/essentials';
|
|
||||||
import { object, string } from 'zod';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/libraries/session.js';
|
|
||||||
import { findUserById } from '#src/queries/user.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
|
|
||||||
import type { AnonymousRouterLegacy, RouterInitArgs } from '../types.js';
|
|
||||||
import continueRoutes from './continue.js';
|
|
||||||
import forgotPasswordRoutes from './forgot-password.js';
|
|
||||||
import koaGuardSessionAction from './middleware/koa-guard-session-action.js';
|
|
||||||
import passwordRoutes from './password.js';
|
|
||||||
import passwordlessRoutes from './passwordless.js';
|
|
||||||
import socialRoutes from './social.js';
|
|
||||||
import { getRoutePrefix } from './utils.js';
|
|
||||||
|
|
||||||
export default function sessionRoutes<T extends AnonymousRouterLegacy>(
|
|
||||||
...[router, { provider }]: RouterInitArgs<T>
|
|
||||||
) {
|
|
||||||
router.use(getRoutePrefix('sign-in'), koaGuardSessionAction(provider, 'sign-in'));
|
|
||||||
router.use(getRoutePrefix('register'), koaGuardSessionAction(provider, 'register'));
|
|
||||||
|
|
||||||
router.post('/session', async (ctx, next) => {
|
|
||||||
const {
|
|
||||||
prompt: { name },
|
|
||||||
} = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
if (name === 'consent') {
|
|
||||||
ctx.body = { redirectTo: path.join(ctx.request.origin, '/session/consent') };
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new RequestError('session.unsupported_prompt_name');
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/session/consent', async (ctx, next) => {
|
|
||||||
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
const {
|
|
||||||
session,
|
|
||||||
grantId,
|
|
||||||
params: { client_id },
|
|
||||||
prompt,
|
|
||||||
} = interaction;
|
|
||||||
assertThat(session, 'session.not_found');
|
|
||||||
|
|
||||||
const { accountId } = session;
|
|
||||||
|
|
||||||
// Temp solution before migrating to RBAC. Block non-admin user from consenting to admin console
|
|
||||||
if (String(client_id) === adminConsoleApplicationId) {
|
|
||||||
const { roleNames } = await findUserById(accountId);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
roleNames.includes(UserRole.Admin),
|
|
||||||
new RequestError({ code: 'auth.forbidden', status: 401 })
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const grant =
|
|
||||||
conditional(grantId && (await provider.Grant.find(grantId))) ??
|
|
||||||
new provider.Grant({ accountId, clientId: String(client_id) });
|
|
||||||
|
|
||||||
await saveUserFirstConsentedAppId(accountId, String(client_id));
|
|
||||||
|
|
||||||
// V2: fulfill missing claims / resources
|
|
||||||
const PromptDetailsBody = object({
|
|
||||||
missingOIDCScope: string().array().optional(),
|
|
||||||
missingResourceScopes: object({}).catchall(string().array()).optional(),
|
|
||||||
});
|
|
||||||
const { missingOIDCScope, missingResourceScopes } = PromptDetailsBody.parse(prompt.details);
|
|
||||||
|
|
||||||
if (missingOIDCScope) {
|
|
||||||
grant.addOIDCScope(missingOIDCScope.join(' '));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingResourceScopes) {
|
|
||||||
for (const [indicator, scope] of Object.entries(missingResourceScopes)) {
|
|
||||||
grant.addResourceScope(indicator, scope.join(' '));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalGrantId = await grant.save();
|
|
||||||
|
|
||||||
// V2: configure consent
|
|
||||||
await assignInteractionResults(ctx, provider, { consent: { grantId: finalGrantId } }, true);
|
|
||||||
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
router.delete('/session', async (ctx, next) => {
|
|
||||||
await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
const error: LogtoErrorCode = 'oidc.aborted';
|
|
||||||
await assignInteractionResults(ctx, provider, { error });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
});
|
|
||||||
|
|
||||||
passwordRoutes(router, provider);
|
|
||||||
passwordlessRoutes(router, provider);
|
|
||||||
socialRoutes(router, provider);
|
|
||||||
continueRoutes(router, provider);
|
|
||||||
forgotPasswordRoutes(router, provider);
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
import { SignInMode, adminConsoleApplicationId } from '@logto/schemas';
|
|
||||||
import type { MiddlewareType } from 'koa';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
import { errors } from 'oidc-provider';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { getApplicationIdFromInteraction } from '#src/libraries/session.js';
|
|
||||||
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
|
|
||||||
export default function koaGuardSessionAction<StateT, ContextT, ResponseBodyT>(
|
|
||||||
provider: Provider,
|
|
||||||
forType: 'sign-in' | 'register'
|
|
||||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
|
||||||
const forbiddenError = new RequestError({ code: 'auth.forbidden', status: 403 });
|
|
||||||
|
|
||||||
return async (ctx, next) => {
|
|
||||||
const interaction = await provider
|
|
||||||
.interactionDetails(ctx.req, ctx.res)
|
|
||||||
.catch((error: unknown) => {
|
|
||||||
// Should not block if interaction is not found
|
|
||||||
if (error instanceof errors.SessionNotFound) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* We don't guard admin console in API for now since logically there's no need.
|
|
||||||
* Update to honor the config if we're implementing per-app SIE.
|
|
||||||
*/
|
|
||||||
if (interaction?.params.client_id === adminConsoleApplicationId) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const { signInMode } = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (forType === 'sign-in') {
|
|
||||||
assertThat(signInMode !== SignInMode.Register, forbiddenError);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forType === 'register') {
|
|
||||||
assertThat(signInMode !== SignInMode.SignIn, forbiddenError);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
}
|
|
|
@ -1,207 +0,0 @@
|
||||||
import { VerificationCodeType } from '@logto/connector-kit';
|
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
|
||||||
import type { MiddlewareType } from 'koa';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import {
|
|
||||||
assignInteractionResults,
|
|
||||||
getApplicationIdFromInteraction,
|
|
||||||
} from '#src/libraries/session.js';
|
|
||||||
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
|
||||||
import { generateUserId, insertUser } from '#src/libraries/user.js';
|
|
||||||
import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js';
|
|
||||||
import {
|
|
||||||
hasUserWithPhone,
|
|
||||||
hasUserWithEmail,
|
|
||||||
findUserByPhone,
|
|
||||||
findUserByEmail,
|
|
||||||
updateUserById,
|
|
||||||
} from '#src/queries/user.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
|
|
||||||
import { smsSessionResultGuard, emailSessionResultGuard } from '../types.js';
|
|
||||||
import {
|
|
||||||
getVerificationStorageFromInteraction,
|
|
||||||
getPasswordlessRelatedLogType,
|
|
||||||
checkValidateExpiration,
|
|
||||||
checkRequiredProfile,
|
|
||||||
} from '../utils.js';
|
|
||||||
|
|
||||||
export const smsSignInAction = <StateT, ContextT extends WithLogContextLegacy, ResponseBodyT>(
|
|
||||||
provider: Provider
|
|
||||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
|
|
||||||
return async (ctx, next) => {
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
assertThat(
|
|
||||||
signInExperience.signIn.methods.some(
|
|
||||||
({ identifier, verificationCode }) =>
|
|
||||||
identifier === SignInIdentifier.Phone && verificationCode
|
|
||||||
),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.sign_in_method_not_enabled',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
|
||||||
ctx,
|
|
||||||
provider,
|
|
||||||
smsSessionResultGuard
|
|
||||||
);
|
|
||||||
|
|
||||||
const type = getPasswordlessRelatedLogType(VerificationCodeType.SignIn, 'sms');
|
|
||||||
ctx.log(type, verificationStorage);
|
|
||||||
|
|
||||||
const { phone, expiresAt } = verificationStorage;
|
|
||||||
|
|
||||||
checkValidateExpiration(expiresAt);
|
|
||||||
|
|
||||||
const user = await findUserByPhone(phone);
|
|
||||||
assertThat(user, new RequestError({ code: 'user.phone_not_exist', status: 404 }));
|
|
||||||
const { id, isSuspended } = user;
|
|
||||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
|
||||||
ctx.log(type, { userId: id });
|
|
||||||
|
|
||||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
|
||||||
await updateUserById(id, { lastSignInAt: Date.now() });
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emailSignInAction = <StateT, ContextT extends WithLogContextLegacy, ResponseBodyT>(
|
|
||||||
provider: Provider
|
|
||||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
|
|
||||||
return async (ctx, next) => {
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
assertThat(
|
|
||||||
signInExperience.signIn.methods.some(
|
|
||||||
({ identifier, verificationCode }) =>
|
|
||||||
identifier === SignInIdentifier.Email && verificationCode
|
|
||||||
),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.sign_in_method_not_enabled',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
|
||||||
ctx,
|
|
||||||
provider,
|
|
||||||
emailSessionResultGuard
|
|
||||||
);
|
|
||||||
|
|
||||||
const type = getPasswordlessRelatedLogType(VerificationCodeType.SignIn, 'email');
|
|
||||||
ctx.log(type, verificationStorage);
|
|
||||||
|
|
||||||
const { email, expiresAt } = verificationStorage;
|
|
||||||
|
|
||||||
checkValidateExpiration(expiresAt);
|
|
||||||
|
|
||||||
const user = await findUserByEmail(email);
|
|
||||||
assertThat(user, new RequestError({ code: 'user.email_not_exist', status: 404 }));
|
|
||||||
const { id, isSuspended } = user;
|
|
||||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
|
||||||
ctx.log(type, { userId: id });
|
|
||||||
|
|
||||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
|
||||||
await updateUserById(id, { lastSignInAt: Date.now() });
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const smsRegisterAction = <StateT, ContextT extends WithLogContextLegacy, ResponseBodyT>(
|
|
||||||
provider: Provider
|
|
||||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
|
|
||||||
return async (ctx, next) => {
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
signInExperience.signUp.identifiers.includes(SignInIdentifier.Phone),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.sign_up_method_not_enabled',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
|
||||||
ctx,
|
|
||||||
provider,
|
|
||||||
smsSessionResultGuard
|
|
||||||
);
|
|
||||||
|
|
||||||
const type = getPasswordlessRelatedLogType(VerificationCodeType.Register, 'sms');
|
|
||||||
ctx.log(type, verificationStorage);
|
|
||||||
|
|
||||||
const { phone, expiresAt } = verificationStorage;
|
|
||||||
|
|
||||||
checkValidateExpiration(expiresAt);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!(await hasUserWithPhone(phone)),
|
|
||||||
new RequestError({ code: 'user.phone_already_in_use', status: 422 })
|
|
||||||
);
|
|
||||||
const id = await generateUserId();
|
|
||||||
ctx.log(type, { userId: id });
|
|
||||||
|
|
||||||
const user = await insertUser({ id, primaryPhone: phone, lastSignInAt: Date.now() });
|
|
||||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const emailRegisterAction = <StateT, ContextT extends WithLogContextLegacy, ResponseBodyT>(
|
|
||||||
provider: Provider
|
|
||||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> => {
|
|
||||||
return async (ctx, next) => {
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
signInExperience.signUp.identifiers.includes(SignInIdentifier.Email),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.sign_up_method_not_enabled',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const verificationStorage = await getVerificationStorageFromInteraction(
|
|
||||||
ctx,
|
|
||||||
provider,
|
|
||||||
emailSessionResultGuard
|
|
||||||
);
|
|
||||||
|
|
||||||
const type = getPasswordlessRelatedLogType(VerificationCodeType.Register, 'email');
|
|
||||||
ctx.log(type, verificationStorage);
|
|
||||||
|
|
||||||
const { email, expiresAt } = verificationStorage;
|
|
||||||
|
|
||||||
checkValidateExpiration(expiresAt);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!(await hasUserWithEmail(email)),
|
|
||||||
new RequestError({ code: 'user.email_already_in_use', status: 422 })
|
|
||||||
);
|
|
||||||
const id = await generateUserId();
|
|
||||||
ctx.log(type, { userId: id });
|
|
||||||
|
|
||||||
const user = await insertUser({ id, primaryEmail: email, lastSignInAt: Date.now() });
|
|
||||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
};
|
|
||||||
};
|
|
|
@ -1,296 +0,0 @@
|
||||||
import type { User } from '@logto/schemas';
|
|
||||||
import { UserRole, SignInIdentifier, adminConsoleApplicationId } from '@logto/schemas';
|
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import { mockSignInExperience, mockUser } from '#src/__mocks__/index.js';
|
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
import passwordRoutes, { registerRoute, signInRoute } from './password.js';
|
|
||||||
|
|
||||||
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const hasUser = jest.fn(async (username: string) => username === 'username1');
|
|
||||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
|
||||||
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const hasActiveUsers = jest.fn(async () => true);
|
|
||||||
const findDefaultSignInExperience = jest.fn(async () => mockSignInExperience);
|
|
||||||
|
|
||||||
jest.mock('#src/queries/user.js', () => ({
|
|
||||||
findUserById: async () => findUserById(),
|
|
||||||
findUserByIdentity: async () => ({ id: mockUser.id, identities: {} }),
|
|
||||||
findUserByPhone: async () => mockUser,
|
|
||||||
findUserByEmail: async () => mockUser,
|
|
||||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
hasUser: async (username: string) => hasUser(username),
|
|
||||||
hasUserWithIdentity: async (connectorId: string, userId: string) =>
|
|
||||||
connectorId === 'connectorId' && userId === mockUser.id,
|
|
||||||
hasUserWithPhone: async (phone: string) => phone === '13000000000',
|
|
||||||
hasUserWithEmail: async (email: string) => email === 'a@a.com',
|
|
||||||
hasActiveUsers: async () => hasActiveUsers(),
|
|
||||||
async findUserByUsername(username: string) {
|
|
||||||
const roleNames = username === 'admin' ? [UserRole.Admin] : [];
|
|
||||||
|
|
||||||
return { ...mockUser, username, roleNames };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/sign-in-experience.js', () => ({
|
|
||||||
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/user.js', () => ({
|
|
||||||
async verifyUserPassword(user: User) {
|
|
||||||
return user;
|
|
||||||
},
|
|
||||||
generateUserId: () => 'user1',
|
|
||||||
encryptUserPassword: (password: string) => ({
|
|
||||||
passwordEncrypted: password + '_user1',
|
|
||||||
passwordEncryptionMethod: 'Argon2i',
|
|
||||||
}),
|
|
||||||
updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
insertUser: async (...args: unknown[]) => insertUser(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/session.js', () => ({
|
|
||||||
...jest.requireActual('#src/libraries/session.js'),
|
|
||||||
getApplicationIdFromInteraction: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const grantSave = jest.fn(async () => 'finalGrantId');
|
|
||||||
const grantAddOIDCScope = jest.fn();
|
|
||||||
const grantAddResourceScope = jest.fn();
|
|
||||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
|
||||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
|
||||||
|
|
||||||
class Grant {
|
|
||||||
static async find(id: string) {
|
|
||||||
return id === 'exists' ? new Grant() : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
save: typeof grantSave;
|
|
||||||
addOIDCScope: typeof grantAddOIDCScope;
|
|
||||||
addResourceScope: typeof grantAddResourceScope;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.save = grantSave;
|
|
||||||
this.addOIDCScope = grantAddOIDCScope;
|
|
||||||
this.addResourceScope = grantAddResourceScope;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock('oidc-provider', () => ({
|
|
||||||
Provider: jest.fn(() => ({
|
|
||||||
Grant,
|
|
||||||
interactionDetails,
|
|
||||||
interactionResult,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
grantSave.mockClear();
|
|
||||||
interactionResult.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('session -> password routes', () => {
|
|
||||||
const sessionRequest = createRequester({
|
|
||||||
// @ts-expect-error will remove once interaction refactor finished
|
|
||||||
anonymousRoutes: passwordRoutes,
|
|
||||||
provider: new Provider(''),
|
|
||||||
middlewares: [
|
|
||||||
async (ctx, next) => {
|
|
||||||
ctx.addLogContext = jest.fn();
|
|
||||||
ctx.log = jest.fn();
|
|
||||||
|
|
||||||
return next();
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
it('POST /session/sign-in/password/username', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/username`).send({
|
|
||||||
username: 'username',
|
|
||||||
password: 'password',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('POST /session/sign-in/password/email', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`).send({
|
|
||||||
email: 'email',
|
|
||||||
password: 'password',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('POST /session/sign-in/password/sms', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`).send({
|
|
||||||
phone: 'phone',
|
|
||||||
password: 'password',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/register/password/username', () => {
|
|
||||||
it('assign result and redirect', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
|
|
||||||
const fakeTime = Date.now();
|
|
||||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
|
||||||
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}/username`)
|
|
||||||
.send({ username: 'username', password: 'password' });
|
|
||||||
expect(insertUser).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 'user1',
|
|
||||||
username: 'username',
|
|
||||||
passwordEncrypted: 'password_user1',
|
|
||||||
passwordEncryptionMethod: 'Argon2i',
|
|
||||||
roleNames: [],
|
|
||||||
lastSignInAt: fakeTime,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: 'user1' } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('register user with admin role for admin console if no active user found', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
params: { client_id: adminConsoleApplicationId },
|
|
||||||
});
|
|
||||||
|
|
||||||
hasActiveUsers.mockResolvedValueOnce(false);
|
|
||||||
|
|
||||||
await sessionRequest
|
|
||||||
.post(`${registerRoute}/username`)
|
|
||||||
.send({ username: 'username', password: 'password' });
|
|
||||||
|
|
||||||
expect(insertUser).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
roleNames: ['admin'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not register user with admin role for admin console if any active user found', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
params: { client_id: adminConsoleApplicationId },
|
|
||||||
});
|
|
||||||
|
|
||||||
await sessionRequest
|
|
||||||
.post(`${registerRoute}/username`)
|
|
||||||
.send({ username: 'username', password: 'password' });
|
|
||||||
|
|
||||||
expect(insertUser).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
roleNames: [],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error if username not valid', async () => {
|
|
||||||
const usernameStartedWithNumber = '1username';
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}/username`)
|
|
||||||
.send({ username: usernameStartedWithNumber, password: 'password' });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error if username exists', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}/username`)
|
|
||||||
.send({ username: 'username1', password: 'password' });
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if sign up identifier is not username', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
|
|
||||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Email],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}/username`)
|
|
||||||
.send({ username: 'username', password: 'password' });
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/register/password/check-username', () => {
|
|
||||||
it('check and return empty', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}/check-username`)
|
|
||||||
.send({ username: 'username' });
|
|
||||||
expect(response.status).toEqual(204);
|
|
||||||
expect(hasUser).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error if username not valid', async () => {
|
|
||||||
const usernameStartedWithNumber = '1username';
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}/check-username`)
|
|
||||||
.send({ username: usernameStartedWithNumber, password: 'password' });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error if username exists', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}/check-username`)
|
|
||||||
.send({ username: 'username1' });
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if sign up identifier is not username', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
|
|
||||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Email],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}/check-username`)
|
|
||||||
.send({ username: 'username' });
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,197 +0,0 @@
|
||||||
import { passwordRegEx, usernameRegEx } from '@logto/core-kit';
|
|
||||||
import { SignInIdentifier, UserRole, adminConsoleApplicationId } from '@logto/schemas';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
import { object, string } from 'zod';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import {
|
|
||||||
assignInteractionResults,
|
|
||||||
getApplicationIdFromInteraction,
|
|
||||||
} from '#src/libraries/session.js';
|
|
||||||
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
|
||||||
import { encryptUserPassword, generateUserId, insertUser } from '#src/libraries/user.js';
|
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
|
||||||
import {
|
|
||||||
findUserByEmail,
|
|
||||||
findUserByPhone,
|
|
||||||
findUserByUsername,
|
|
||||||
hasActiveUsers,
|
|
||||||
hasUser,
|
|
||||||
} from '#src/queries/user.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
|
|
||||||
import type { AnonymousRouterLegacy } from '../types.js';
|
|
||||||
import { checkRequiredProfile, getRoutePrefix, signInWithPassword } from './utils.js';
|
|
||||||
|
|
||||||
export const registerRoute = getRoutePrefix('register', 'password');
|
|
||||||
export const signInRoute = getRoutePrefix('sign-in', 'password');
|
|
||||||
|
|
||||||
export default function passwordRoutes<T extends AnonymousRouterLegacy>(
|
|
||||||
router: T,
|
|
||||||
provider: Provider
|
|
||||||
) {
|
|
||||||
router.post(
|
|
||||||
`${signInRoute}/username`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
username: string().min(1),
|
|
||||||
password: string().min(1),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { username, password } = ctx.guard.body;
|
|
||||||
const type = 'SignInUsernamePassword';
|
|
||||||
await signInWithPassword(ctx, provider, {
|
|
||||||
identifier: SignInIdentifier.Username,
|
|
||||||
password,
|
|
||||||
logType: type,
|
|
||||||
logPayload: { username },
|
|
||||||
findUser: async () => findUserByUsername(username),
|
|
||||||
});
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
`${signInRoute}/email`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
email: string().min(1),
|
|
||||||
password: string().min(1),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { email, password } = ctx.guard.body;
|
|
||||||
const type = 'SignInEmailPassword';
|
|
||||||
await signInWithPassword(ctx, provider, {
|
|
||||||
identifier: SignInIdentifier.Email,
|
|
||||||
password,
|
|
||||||
logType: type,
|
|
||||||
logPayload: { email },
|
|
||||||
findUser: async () => findUserByEmail(email),
|
|
||||||
});
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
`${signInRoute}/sms`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
phone: string().min(1),
|
|
||||||
password: string().min(1),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { phone, password } = ctx.guard.body;
|
|
||||||
const type = 'SignInSmsPassword';
|
|
||||||
await signInWithPassword(ctx, provider, {
|
|
||||||
identifier: SignInIdentifier.Phone,
|
|
||||||
password,
|
|
||||||
logType: type,
|
|
||||||
logPayload: { phone },
|
|
||||||
findUser: async () => findUserByPhone(phone),
|
|
||||||
});
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
`${registerRoute}/check-username`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
username: string().regex(usernameRegEx),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { username } = ctx.guard.body;
|
|
||||||
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
assertThat(
|
|
||||||
signInExperience.signUp.identifiers.includes(SignInIdentifier.Username),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.sign_up_method_not_enabled',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!(await hasUser(username)),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.username_already_in_use',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
`${registerRoute}/username`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
username: string().regex(usernameRegEx),
|
|
||||||
password: string().regex(passwordRegEx),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { username, password } = ctx.guard.body;
|
|
||||||
const type = 'RegisterUsernamePassword';
|
|
||||||
ctx.log(type, { username });
|
|
||||||
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
assertThat(
|
|
||||||
signInExperience.signUp.identifiers.includes(SignInIdentifier.Username),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.sign_up_method_not_enabled',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
assertThat(
|
|
||||||
!(await hasUser(username)),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.username_already_in_use',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
params: { client_id },
|
|
||||||
} = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
const createAdminUser =
|
|
||||||
String(client_id) === adminConsoleApplicationId && !(await hasActiveUsers());
|
|
||||||
const roleNames = createAdminUser ? [UserRole.Admin] : [];
|
|
||||||
|
|
||||||
const id = await generateUserId();
|
|
||||||
|
|
||||||
ctx.log(type, { userId: id, roleNames });
|
|
||||||
|
|
||||||
const { passwordEncrypted, passwordEncryptionMethod } = await encryptUserPassword(password);
|
|
||||||
|
|
||||||
const user = await insertUser({
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
passwordEncrypted,
|
|
||||||
passwordEncryptionMethod,
|
|
||||||
roleNames,
|
|
||||||
lastSignInAt: Date.now(),
|
|
||||||
});
|
|
||||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,964 +0,0 @@
|
||||||
/* eslint-disable max-lines */
|
|
||||||
import { VerificationCodeType } from '@logto/connector-kit';
|
|
||||||
import type { User } from '@logto/schemas';
|
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
|
||||||
import type { Nullable } from '@silverhand/essentials';
|
|
||||||
import { addDays, addSeconds, subDays } from 'date-fns';
|
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import { mockSignInExperience, mockSignInMethod, mockUser } from '#src/__mocks__/index.js';
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
import { verificationTimeout } from '../consts.js';
|
|
||||||
import * as passwordlessActions from './middleware/passwordless-action.js';
|
|
||||||
import passwordlessRoutes, { registerRoute, signInRoute } from './passwordless.js';
|
|
||||||
|
|
||||||
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
|
||||||
const findUserByEmail = jest.fn(async (): Promise<Nullable<User>> => mockUser);
|
|
||||||
const findUserByPhone = jest.fn(async (): Promise<Nullable<User>> => mockUser);
|
|
||||||
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const findDefaultSignInExperience = jest.fn(async () => ({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Username],
|
|
||||||
password: false,
|
|
||||||
verify: true,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
const getTomorrowIsoString = () => addDays(Date.now(), 1).toISOString();
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/user.js', () => ({
|
|
||||||
generateUserId: () => 'user1',
|
|
||||||
insertUser: async (...args: unknown[]) => insertUser(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/session.js', () => ({
|
|
||||||
...jest.requireActual('#src/libraries/session.js'),
|
|
||||||
getApplicationIdFromInteraction: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/user.js', () => ({
|
|
||||||
findUserById: async () => findUserById(),
|
|
||||||
findUserByPhone: async () => findUserByPhone(),
|
|
||||||
findUserByEmail: async () => findUserByEmail(),
|
|
||||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
hasUser: async (username: string) => username === 'username1',
|
|
||||||
hasUserWithPhone: async (phone: string) => phone === '13000000000',
|
|
||||||
hasUserWithEmail: async (email: string) => email === 'a@a.com',
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/sign-in-experience.js', () => ({
|
|
||||||
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
|
|
||||||
}));
|
|
||||||
const smsSignInActionSpy = jest.spyOn(passwordlessActions, 'smsSignInAction');
|
|
||||||
const emailSignInActionSpy = jest.spyOn(passwordlessActions, 'emailSignInAction');
|
|
||||||
const smsRegisterActionSpy = jest.spyOn(passwordlessActions, 'smsRegisterAction');
|
|
||||||
const emailRegisterActionSpy = jest.spyOn(passwordlessActions, 'emailRegisterAction');
|
|
||||||
|
|
||||||
const sendPasscode = jest.fn(async () => ({ dbEntry: { id: 'connectorIdValue' } }));
|
|
||||||
const createPasscode = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
|
||||||
jest.mock('#src/libraries/passcode.js', () => ({
|
|
||||||
createPasscode: async (..._args: unknown[]) => createPasscode(..._args),
|
|
||||||
sendPasscode: async () => sendPasscode(),
|
|
||||||
verifyPasscode: async (_a: unknown, _b: unknown, code: string) => {
|
|
||||||
if (code !== '1234') {
|
|
||||||
throw new RequestError('verification_code.code_mismatch');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
|
||||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
|
||||||
|
|
||||||
jest.mock('oidc-provider', () => ({
|
|
||||||
Provider: jest.fn(() => ({
|
|
||||||
interactionDetails,
|
|
||||||
interactionResult,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
interactionResult.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('session -> passwordlessRoutes', () => {
|
|
||||||
const sessionRequest = createRequester({
|
|
||||||
// @ts-expect-error will remove once interaction refactor finished
|
|
||||||
anonymousRoutes: passwordlessRoutes,
|
|
||||||
provider: new Provider(''),
|
|
||||||
middlewares: [
|
|
||||||
async (ctx, next) => {
|
|
||||||
ctx.addLogContext = jest.fn();
|
|
||||||
ctx.log = jest.fn();
|
|
||||||
|
|
||||||
return next();
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/passwordless/sms/send', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('should call sendPasscode (with flow `sign-in`)', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/send')
|
|
||||||
.send({ phone: '13000000000', flow: VerificationCodeType.SignIn });
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.SignIn, {
|
|
||||||
phone: '13000000000',
|
|
||||||
});
|
|
||||||
expect(sendPasscode).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('should call sendPasscode (with flow `register`)', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/send')
|
|
||||||
.send({ phone: '13000000000', flow: VerificationCodeType.Register });
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.Register, {
|
|
||||||
phone: '13000000000',
|
|
||||||
});
|
|
||||||
expect(sendPasscode).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('should call sendPasscode (with flow `forgot-password`)', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/send')
|
|
||||||
.send({ phone: '13000000000', flow: VerificationCodeType.ForgotPassword });
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.ForgotPassword, {
|
|
||||||
phone: '13000000000',
|
|
||||||
});
|
|
||||||
expect(sendPasscode).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('throw when phone not given in input params', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/send')
|
|
||||||
.send({ flow: VerificationCodeType.Register });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/passwordless/email/send', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('should call sendPasscode (with flow `sign-in`)', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/email/send')
|
|
||||||
.send({ email: 'a@a.com', flow: VerificationCodeType.SignIn });
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.SignIn, {
|
|
||||||
email: 'a@a.com',
|
|
||||||
});
|
|
||||||
expect(sendPasscode).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('should call sendPasscode (with flow `register`)', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/email/send')
|
|
||||||
.send({ email: 'a@a.com', flow: VerificationCodeType.Register });
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.Register, {
|
|
||||||
email: 'a@a.com',
|
|
||||||
});
|
|
||||||
expect(sendPasscode).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('should call sendPasscode (with flow `forgot-password`)', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/email/send')
|
|
||||||
.send({ email: 'a@a.com', flow: VerificationCodeType.ForgotPassword });
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
expect(createPasscode).toHaveBeenCalledWith('jti', VerificationCodeType.ForgotPassword, {
|
|
||||||
email: 'a@a.com',
|
|
||||||
});
|
|
||||||
expect(sendPasscode).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('throw when email not given in input params', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/email/send')
|
|
||||||
.send({ flow: VerificationCodeType.Register });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/passwordless/sms/verify', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
|
||||||
const fakeTime = new Date();
|
|
||||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
|
||||||
|
|
||||||
await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/verify')
|
|
||||||
.send({ phone: '13000000000', code: '1234', flow: VerificationCodeType.SignIn });
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
verification: {
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
phone: '13000000000',
|
|
||||||
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should call sign-in with sms properly
|
|
||||||
expect(smsSignInActionSpy).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `register`)', async () => {
|
|
||||||
const fakeTime = new Date();
|
|
||||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
|
||||||
|
|
||||||
await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/verify')
|
|
||||||
.send({ phone: '13000000000', code: '1234', flow: VerificationCodeType.Register });
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
verification: {
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
phone: '13000000000',
|
|
||||||
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(smsRegisterActionSpy).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `forgot-password`)', async () => {
|
|
||||||
const fakeTime = new Date();
|
|
||||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
|
||||||
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/verify')
|
|
||||||
.send({ phone: '13000000000', code: '1234', flow: VerificationCodeType.ForgotPassword });
|
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
verification: {
|
|
||||||
userId: mockUser.id,
|
|
||||||
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw 404 (with flow `forgot-password`)', async () => {
|
|
||||||
findUserByPhone.mockResolvedValueOnce(null);
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/verify')
|
|
||||||
.send({ phone: '13000000001', code: '1234', flow: VerificationCodeType.ForgotPassword });
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
expect(interactionResult).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when code is wrong', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/sms/verify')
|
|
||||||
.send({ phone: '13000000000', code: '1231', flow: VerificationCodeType.SignIn });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/passwordless/email/verify', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
|
||||||
const fakeTime = new Date();
|
|
||||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
|
||||||
|
|
||||||
await sessionRequest
|
|
||||||
.post('/session/passwordless/email/verify')
|
|
||||||
.send({ email: 'a@a.com', code: '1234', flow: VerificationCodeType.SignIn });
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
verification: {
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
email: 'a@a.com',
|
|
||||||
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(emailSignInActionSpy).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `register`)', async () => {
|
|
||||||
const fakeTime = new Date();
|
|
||||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
|
||||||
|
|
||||||
await sessionRequest
|
|
||||||
.post('/session/passwordless/email/verify')
|
|
||||||
.send({ email: 'a@a.com', code: '1234', flow: VerificationCodeType.Register });
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
verification: {
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
email: 'a@a.com',
|
|
||||||
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(emailRegisterActionSpy).toBeCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `forgot-password`)', async () => {
|
|
||||||
const fakeTime = new Date();
|
|
||||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
|
||||||
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/email/verify')
|
|
||||||
.send({ email: 'a@a.com', code: '1234', flow: VerificationCodeType.ForgotPassword });
|
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(204);
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
verification: {
|
|
||||||
userId: mockUser.id,
|
|
||||||
expiresAt: addSeconds(fakeTime, verificationTimeout).toISOString(),
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw 404 (with flow `forgot-password`)', async () => {
|
|
||||||
const fakeTime = new Date();
|
|
||||||
jest.useFakeTimers().setSystemTime(fakeTime);
|
|
||||||
findUserByEmail.mockResolvedValueOnce(null);
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/email/verify')
|
|
||||||
.send({ email: 'b@a.com', code: '1234', flow: VerificationCodeType.ForgotPassword });
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
expect(interactionResult).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when code is wrong', async () => {
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post('/session/passwordless/email/verify')
|
|
||||||
.send({ email: 'a@a.com', code: '1231', flow: VerificationCodeType.SignIn });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/passwordless/sms', () => {
|
|
||||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000000',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
login: { accountId: mockUser.id },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `register`)', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000000',
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
login: { accountId: mockUser.id },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when verification session invalid', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000000',
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when flow is not `sign-in` and `register`', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000000',
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when expiresAt is not valid ISO date string', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000000',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: 'invalid date string',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when validation expired', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000000',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: subDays(Date.now(), 1).toISOString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when phone not exist', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'XX@foo',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw when phone not exist as user's primaryPhone", async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000001',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
findUserByPhone.mockResolvedValueOnce(null);
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when user is suspended', async () => {
|
|
||||||
findUserByPhone.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
isSuspended: true,
|
|
||||||
});
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000000',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error if sign in method is not enabled', async () => {
|
|
||||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signIn: {
|
|
||||||
methods: [
|
|
||||||
{
|
|
||||||
...mockSignInMethod,
|
|
||||||
identifier: SignInIdentifier.Username,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/passwordless/email', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
findDefaultSignInExperience.mockResolvedValue({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Email],
|
|
||||||
password: false,
|
|
||||||
verify: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
findDefaultSignInExperience.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'a@a.com',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
login: { accountId: mockUser.id },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `register`)', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'a@a.com',
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
|
||||||
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
login: { accountId: mockUser.id },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when verification session invalid', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'a@a.com',
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when flow is not `sign-in` and `register`', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'a@a.com',
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when email not exist', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw when email not exist as user's primaryEmail", async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'b@a.com',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
findUserByEmail.mockResolvedValueOnce(null);
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when user is suspended', async () => {
|
|
||||||
findUserByEmail.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
isSuspended: true,
|
|
||||||
});
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'a@a.com',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error if sign in method is not enabled', async () => {
|
|
||||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signIn: {
|
|
||||||
methods: [
|
|
||||||
{
|
|
||||||
...mockSignInMethod,
|
|
||||||
identifier: SignInIdentifier.Username,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/register/passwordless/sms', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
findDefaultSignInExperience.mockResolvedValue({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Phone],
|
|
||||||
password: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
findDefaultSignInExperience.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `register`)', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000001',
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
login: { accountId: 'user1' },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000001',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
login: { accountId: 'user1' },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when verification session invalid', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000001',
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when flow is not `register` and `sign-in`', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000001',
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when phone not exist', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw when phone already exist as user's primaryPhone", async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
phone: '13000000000',
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if sign up identifier does not contain phone', async () => {
|
|
||||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Email],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/sms`);
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/register/passwordless/email', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
findDefaultSignInExperience.mockResolvedValue({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Email],
|
|
||||||
password: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
findDefaultSignInExperience.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `register`)', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'b@a.com',
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
login: { accountId: 'user1' },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call interactionResult (with flow `sign-in`)', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'b@a.com',
|
|
||||||
flow: VerificationCodeType.SignIn,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
login: { accountId: 'user1' },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when verification session invalid', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'b@a.com',
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when flow is not `register` and `sign-in`', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'b@a.com',
|
|
||||||
flow: VerificationCodeType.ForgotPassword,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw when email not exist', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw when email already exist as user's primaryEmail", async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
verification: {
|
|
||||||
email: 'a@a.com',
|
|
||||||
flow: VerificationCodeType.Register,
|
|
||||||
expiresAt: getTomorrowIsoString(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if sign up identifier does not contain email', async () => {
|
|
||||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Phone],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await sessionRequest.post(`${registerRoute}/email`);
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
/* eslint-enable max-lines */
|
|
|
@ -1,192 +0,0 @@
|
||||||
import { VerificationCodeType } from '@logto/connector-kit';
|
|
||||||
import { emailRegEx, phoneRegEx } from '@logto/core-kit';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
import { object, string } from 'zod';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { createPasscode, sendPasscode, verifyPasscode } from '#src/libraries/passcode.js';
|
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
|
||||||
import { findUserByEmail, findUserByPhone } from '#src/queries/user.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
|
|
||||||
import type { AnonymousRouterLegacy } from '../types.js';
|
|
||||||
import {
|
|
||||||
smsSignInAction,
|
|
||||||
emailSignInAction,
|
|
||||||
smsRegisterAction,
|
|
||||||
emailRegisterAction,
|
|
||||||
} from './middleware/passwordless-action.js';
|
|
||||||
import { flowTypeGuard } from './types.js';
|
|
||||||
import {
|
|
||||||
assignVerificationResult,
|
|
||||||
getPasswordlessRelatedLogType,
|
|
||||||
getRoutePrefix,
|
|
||||||
} from './utils.js';
|
|
||||||
|
|
||||||
export const registerRoute = getRoutePrefix('register', 'passwordless');
|
|
||||||
export const signInRoute = getRoutePrefix('sign-in', 'passwordless');
|
|
||||||
|
|
||||||
export default function passwordlessRoutes<T extends AnonymousRouterLegacy>(
|
|
||||||
router: T,
|
|
||||||
provider: Provider
|
|
||||||
) {
|
|
||||||
router.post(
|
|
||||||
'/session/passwordless/sms/send',
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
phone: string().regex(phoneRegEx),
|
|
||||||
flow: flowTypeGuard,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
const {
|
|
||||||
body: { phone, flow },
|
|
||||||
} = ctx.guard;
|
|
||||||
|
|
||||||
const type = getPasswordlessRelatedLogType(flow, 'sms', 'send');
|
|
||||||
ctx.log(type, { phone });
|
|
||||||
|
|
||||||
const passcode = await createPasscode(jti, flow, { phone });
|
|
||||||
const { dbEntry } = await sendPasscode(passcode);
|
|
||||||
ctx.log(type, { connectorId: dbEntry.id });
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/session/passwordless/email/send',
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
email: string().regex(emailRegEx),
|
|
||||||
flow: flowTypeGuard,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
const {
|
|
||||||
body: { email, flow },
|
|
||||||
} = ctx.guard;
|
|
||||||
|
|
||||||
const type = getPasswordlessRelatedLogType(flow, 'email', 'send');
|
|
||||||
ctx.log(type, { email });
|
|
||||||
|
|
||||||
const passcode = await createPasscode(jti, flow, { email });
|
|
||||||
const { dbEntry } = await sendPasscode(passcode);
|
|
||||||
ctx.log(type, { connectorId: dbEntry.id });
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/session/passwordless/sms/verify',
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
phone: string().regex(phoneRegEx),
|
|
||||||
code: string(),
|
|
||||||
flow: flowTypeGuard,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
const {
|
|
||||||
body: { phone, code, flow },
|
|
||||||
} = ctx.guard;
|
|
||||||
|
|
||||||
const type = getPasswordlessRelatedLogType(flow, 'sms', 'verify');
|
|
||||||
ctx.log(type, { phone });
|
|
||||||
|
|
||||||
await verifyPasscode(jti, flow, code, { phone });
|
|
||||||
|
|
||||||
if (flow === VerificationCodeType.ForgotPassword) {
|
|
||||||
const user = await findUserByPhone(phone);
|
|
||||||
assertThat(user, new RequestError({ code: 'user.phone_not_exist', status: 404 }));
|
|
||||||
|
|
||||||
await assignVerificationResult(ctx, provider, { flow, userId: user.id });
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flow === VerificationCodeType.SignIn) {
|
|
||||||
await assignVerificationResult(ctx, provider, { flow, phone });
|
|
||||||
|
|
||||||
return smsSignInAction(provider)(ctx, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flow === VerificationCodeType.Register) {
|
|
||||||
await assignVerificationResult(ctx, provider, { flow, phone });
|
|
||||||
|
|
||||||
return smsRegisterAction(provider)(ctx, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
await assignVerificationResult(ctx, provider, { flow, phone });
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/session/passwordless/email/verify',
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
email: string().regex(emailRegEx),
|
|
||||||
code: string(),
|
|
||||||
flow: flowTypeGuard,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
const {
|
|
||||||
body: { email, code, flow },
|
|
||||||
} = ctx.guard;
|
|
||||||
|
|
||||||
const type = getPasswordlessRelatedLogType(flow, 'email', 'verify');
|
|
||||||
ctx.log(type, { email });
|
|
||||||
|
|
||||||
await verifyPasscode(jti, flow, code, { email });
|
|
||||||
|
|
||||||
if (flow === VerificationCodeType.ForgotPassword) {
|
|
||||||
const user = await findUserByEmail(email);
|
|
||||||
|
|
||||||
assertThat(user, new RequestError({ code: 'user.email_not_exist', status: 404 }));
|
|
||||||
|
|
||||||
await assignVerificationResult(ctx, provider, { flow, userId: user.id });
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flow === VerificationCodeType.SignIn) {
|
|
||||||
await assignVerificationResult(ctx, provider, { flow, email });
|
|
||||||
|
|
||||||
return emailSignInAction(provider)(ctx, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flow === VerificationCodeType.Register) {
|
|
||||||
await assignVerificationResult(ctx, provider, { flow, email });
|
|
||||||
|
|
||||||
return emailRegisterAction(provider)(ctx, next);
|
|
||||||
}
|
|
||||||
|
|
||||||
await assignVerificationResult(ctx, provider, { flow, email });
|
|
||||||
ctx.status = 204;
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(`${signInRoute}/sms`, smsSignInAction(provider));
|
|
||||||
|
|
||||||
router.post(`${signInRoute}/email`, emailSignInAction(provider));
|
|
||||||
|
|
||||||
router.post(`${registerRoute}/sms`, smsRegisterAction(provider));
|
|
||||||
|
|
||||||
router.post(`${registerRoute}/email`, emailRegisterAction(provider));
|
|
||||||
}
|
|
|
@ -1,193 +0,0 @@
|
||||||
import { ConnectorType } from '@logto/connector-kit';
|
|
||||||
import type { User } from '@logto/schemas';
|
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import { mockLogtoConnectorList, mockSignInExperience, mockUser } from '#src/__mocks__/index.js';
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { getLogtoConnectorById } from '#src/libraries/connector.js';
|
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
import socialRoutes, { registerRoute } from './social.js';
|
|
||||||
|
|
||||||
const findSocialRelatedUser = jest.fn(async () => [
|
|
||||||
'phone',
|
|
||||||
{ id: 'user1', identities: {}, isSuspended: false },
|
|
||||||
]);
|
|
||||||
jest.mock('#src/libraries/social.js', () => ({
|
|
||||||
...jest.requireActual('#src/libraries/social.js'),
|
|
||||||
findSocialRelatedUser: async () => findSocialRelatedUser(),
|
|
||||||
async getUserInfoByAuthCode(connectorId: string, data: { code: string }) {
|
|
||||||
if (connectorId === '_connectorId') {
|
|
||||||
throw new RequestError({
|
|
||||||
code: 'session.invalid_connector_id',
|
|
||||||
status: 422,
|
|
||||||
connectorId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.code === '123456') {
|
|
||||||
return { id: mockUser.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
// This mocks the case that can not get userInfo with access token and auth code
|
|
||||||
// (most likely third-party social connectors' problem).
|
|
||||||
throw new Error(' ');
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
|
||||||
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const findUserByIdentity = jest.fn(async () => mockUser);
|
|
||||||
|
|
||||||
jest.mock('#src/queries/user.js', () => ({
|
|
||||||
findUserById: async () => findUserById(),
|
|
||||||
findUserByIdentity: async () => findUserByIdentity(),
|
|
||||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
hasUserWithIdentity: async (target: string, userId: string) =>
|
|
||||||
target === 'connectorTarget' && userId === mockUser.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/user.js', () => ({
|
|
||||||
generateUserId: () => 'user1',
|
|
||||||
insertUser: async (...args: unknown[]) => insertUser(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/sign-in-experience.js', () => ({
|
|
||||||
findDefaultSignInExperience: async () => ({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
|
|
||||||
const database = {
|
|
||||||
enabled: connectorId === 'social_enabled',
|
|
||||||
};
|
|
||||||
const metadata = {
|
|
||||||
id:
|
|
||||||
connectorId === 'social_enabled'
|
|
||||||
? 'social_enabled'
|
|
||||||
: connectorId === 'social_disabled'
|
|
||||||
? 'social_disabled'
|
|
||||||
: 'others',
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
dbEntry: database,
|
|
||||||
metadata,
|
|
||||||
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms,
|
|
||||||
getAuthorizationUri: jest.fn(async () => ''),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/connector.js', () => ({
|
|
||||||
getLogtoConnectors: jest.fn(async () => mockLogtoConnectorList),
|
|
||||||
getLogtoConnectorById: jest.fn(async (connectorId: string) => {
|
|
||||||
const connector = await getLogtoConnectorByIdHelper(connectorId);
|
|
||||||
|
|
||||||
if (connector.type !== ConnectorType.Social) {
|
|
||||||
throw new RequestError({
|
|
||||||
code: 'entity.not_found',
|
|
||||||
status: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return connector;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
|
||||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
|
||||||
|
|
||||||
jest.mock('oidc-provider', () => ({
|
|
||||||
Provider: jest.fn(() => ({
|
|
||||||
interactionDetails,
|
|
||||||
interactionResult,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
interactionResult.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('session -> socialRoutes', () => {
|
|
||||||
const sessionRequest = createRequester({
|
|
||||||
// @ts-expect-error will remove once interaction refactor finished
|
|
||||||
anonymousRoutes: socialRoutes,
|
|
||||||
provider: new Provider(''),
|
|
||||||
middlewares: [
|
|
||||||
async (ctx, next) => {
|
|
||||||
ctx.addLogContext = jest.fn();
|
|
||||||
ctx.log = jest.fn();
|
|
||||||
|
|
||||||
return next();
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/register/social', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock;
|
|
||||||
mockGetLogtoConnectorById.mockResolvedValueOnce({
|
|
||||||
metadata: { target: 'connectorTarget' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('register with social, assign result and redirect', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
jti: 'jti',
|
|
||||||
result: {
|
|
||||||
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: 'user1' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}`)
|
|
||||||
.send({ connectorId: 'connectorId' });
|
|
||||||
expect(insertUser).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 'user1',
|
|
||||||
identities: { connectorTarget: { userId: 'user1', details: { id: 'user1' } } },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: 'user1' } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error if no result can be found in interactionResults', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}`)
|
|
||||||
.send({ connectorId: 'connectorId' });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error if result parsing fails', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ result: { login: { accountId: mockUser.id } } });
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}`)
|
|
||||||
.send({ connectorId: 'connectorId' });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when user with identity exists', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
login: { accountId: 'user1' },
|
|
||||||
socialUserInfo: { connectorId: 'connectorId', userInfo: { id: mockUser.id } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest
|
|
||||||
.post(`${registerRoute}`)
|
|
||||||
.send({ connectorId: 'connectorId' });
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,425 +0,0 @@
|
||||||
import { ConnectorType } from '@logto/connector-kit';
|
|
||||||
import type { SocialUserInfo } from '@logto/connector-kit';
|
|
||||||
import type { User } from '@logto/schemas';
|
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import { mockSignInExperience, mockUser } from '#src/__mocks__/index.js';
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { getLogtoConnectorById } from '#src/libraries/connector.js';
|
|
||||||
import { createRequester } from '#src/utils/test-utils.js';
|
|
||||||
|
|
||||||
import socialRoutes, { signInRoute } from './social.js';
|
|
||||||
|
|
||||||
const findSocialRelatedUser = jest.fn(async () => [
|
|
||||||
'phone',
|
|
||||||
{ id: 'user1', identities: {}, isSuspended: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const getUserInfoByAuthCode = jest.fn(
|
|
||||||
async (connectorId: string, data: { code: string }): Promise<SocialUserInfo> => {
|
|
||||||
if (connectorId === '_connectorId') {
|
|
||||||
throw new RequestError({
|
|
||||||
code: 'session.invalid_connector_id',
|
|
||||||
status: 422,
|
|
||||||
connectorId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.code === '123456') {
|
|
||||||
return { id: mockUser.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
// This mocks the case that can not get userInfo with access token and auth code
|
|
||||||
// (most likely third-party social connectors' problem).
|
|
||||||
throw new Error(' ');
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/social.js', () => ({
|
|
||||||
...jest.requireActual('#src/libraries/social.js'),
|
|
||||||
findSocialRelatedUser: async () => findSocialRelatedUser(),
|
|
||||||
getUserInfoByAuthCode: async (connectorId: string, data: { code: string }) =>
|
|
||||||
getUserInfoByAuthCode(connectorId, data),
|
|
||||||
}));
|
|
||||||
const insertUser = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
|
||||||
const updateUserById = jest.fn(async (..._args: unknown[]) => mockUser);
|
|
||||||
const findUserByIdentity = jest.fn().mockResolvedValue(mockUser);
|
|
||||||
|
|
||||||
jest.mock('#src/queries/user.js', () => ({
|
|
||||||
findUserById: async () => findUserById(),
|
|
||||||
findUserByIdentity: async () => findUserByIdentity(),
|
|
||||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
hasUserWithIdentity: async (target: string, userId: string) =>
|
|
||||||
target === 'connectorTarget' && userId === mockUser.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/user.js', () => ({
|
|
||||||
generateUserId: () => 'user1',
|
|
||||||
insertUser: async (...args: unknown[]) => insertUser(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/sign-in-experience.js', () => ({
|
|
||||||
findDefaultSignInExperience: async () => ({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const getLogtoConnectorByIdHelper = jest.fn(async (connectorId: string) => {
|
|
||||||
const database = {
|
|
||||||
enabled: connectorId === 'social_enabled',
|
|
||||||
};
|
|
||||||
const metadata = {
|
|
||||||
id:
|
|
||||||
connectorId === 'social_enabled'
|
|
||||||
? 'social_enabled'
|
|
||||||
: connectorId === 'social_disabled'
|
|
||||||
? 'social_disabled'
|
|
||||||
: 'others',
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
dbEntry: database,
|
|
||||||
metadata,
|
|
||||||
type: connectorId.startsWith('social') ? ConnectorType.Social : ConnectorType.Sms,
|
|
||||||
getAuthorizationUri: jest.fn(async () => ''),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/connector.js', () => ({
|
|
||||||
getLogtoConnectorById: jest.fn(async (connectorId: string) => {
|
|
||||||
const connector = await getLogtoConnectorByIdHelper(connectorId);
|
|
||||||
|
|
||||||
if (connector.type !== ConnectorType.Social) {
|
|
||||||
throw new RequestError({
|
|
||||||
code: 'entity.not_found',
|
|
||||||
status: 404,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return connector;
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
|
||||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
|
||||||
|
|
||||||
jest.mock('oidc-provider', () => ({
|
|
||||||
Provider: jest.fn(() => ({
|
|
||||||
interactionDetails,
|
|
||||||
interactionResult,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
interactionResult.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('session -> socialRoutes', () => {
|
|
||||||
const sessionRequest = createRequester({
|
|
||||||
// @ts-expect-error will remove once interaction refactor finished
|
|
||||||
anonymousRoutes: socialRoutes,
|
|
||||||
provider: new Provider(''),
|
|
||||||
middlewares: [
|
|
||||||
async (ctx, next) => {
|
|
||||||
ctx.addLogContext = jest.fn();
|
|
||||||
ctx.log = jest.fn();
|
|
||||||
|
|
||||||
return next();
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/social', () => {
|
|
||||||
it('should throw when redirectURI is invalid', async () => {
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}`).send({
|
|
||||||
connectorId: 'social_enabled',
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'logto.dev',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sign-in with social and redirect', async () => {
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}`).send({
|
|
||||||
connectorId: 'social_enabled',
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
});
|
|
||||||
expect(response.body).toHaveProperty('redirectTo', '');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when sign-in with social but miss state', async () => {
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}`).send({
|
|
||||||
connectorId: 'social_enabled',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when sign-in with social but miss redirectUri', async () => {
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}`).send({
|
|
||||||
connectorId: 'social_enabled',
|
|
||||||
state: 'state',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when no social connector is found', async () => {
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}`).send({
|
|
||||||
connectorId: 'others',
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/social/auth', () => {
|
|
||||||
const connectorTarget = 'connectorTarget';
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when auth code is wrong', async () => {
|
|
||||||
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
|
|
||||||
metadata: { target: connectorTarget },
|
|
||||||
dbEntry: { syncProfile: false },
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
code: '123455',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when code is provided but connector can not be found', async () => {
|
|
||||||
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
|
|
||||||
metadata: { target: connectorTarget },
|
|
||||||
dbEntry: { syncProfile: false },
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
|
|
||||||
connectorId: '_connectorId',
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
code: '123456',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('get and add user info with auth code, as well as assign result and redirect', async () => {
|
|
||||||
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
|
|
||||||
metadata: { target: connectorTarget },
|
|
||||||
dbEntry: { syncProfile: false },
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
data: {
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
code: '123456',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(updateUserById).toHaveBeenCalledWith(
|
|
||||||
mockUser.id,
|
|
||||||
expect.objectContaining({
|
|
||||||
identities: {
|
|
||||||
...mockUser.identities,
|
|
||||||
connectorTarget: { userId: mockUser.id, details: { id: mockUser.id } },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when user is suspended', async () => {
|
|
||||||
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
|
|
||||||
metadata: { target: connectorTarget },
|
|
||||||
dbEntry: { syncProfile: false },
|
|
||||||
});
|
|
||||||
findUserByIdentity.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
isSuspended: true,
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
data: {
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
code: '123456',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw error when identity exists', async () => {
|
|
||||||
findUserByIdentity.mockResolvedValueOnce(null);
|
|
||||||
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
|
|
||||||
metadata: { target: connectorTarget },
|
|
||||||
dbEntry: { syncProfile: false },
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post(`${signInRoute}/auth`).send({
|
|
||||||
connectorId: '_connectorId_',
|
|
||||||
data: {
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
code: '123456',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
socialUserInfo: { connectorId: '_connectorId_', userInfo: { id: mockUser.id } },
|
|
||||||
}),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
expect(response.statusCode).toEqual(422);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update `name` and `avatar` if exists when `syncProfile` is set to be true', async () => {
|
|
||||||
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
|
|
||||||
metadata: { target: connectorTarget },
|
|
||||||
dbEntry: { syncProfile: true },
|
|
||||||
});
|
|
||||||
findUserByIdentity.mockResolvedValueOnce(mockUser);
|
|
||||||
getUserInfoByAuthCode.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
name: 'new_name',
|
|
||||||
avatar: 'new_avatar',
|
|
||||||
});
|
|
||||||
await sessionRequest.post(`${signInRoute}/auth`).send({
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
data: {
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
code: '123456',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(updateUserById).toHaveBeenCalledWith(
|
|
||||||
mockUser.id,
|
|
||||||
expect.objectContaining({ name: 'new_name', avatar: 'new_avatar' })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not update `name` and `avatar` if exists when `syncProfile` is set to be false', async () => {
|
|
||||||
(getLogtoConnectorById as jest.Mock).mockResolvedValueOnce({
|
|
||||||
metadata: { target: connectorTarget },
|
|
||||||
dbEntry: { syncProfile: true },
|
|
||||||
});
|
|
||||||
findUserByIdentity.mockResolvedValueOnce(mockUser);
|
|
||||||
getUserInfoByAuthCode.mockResolvedValueOnce({
|
|
||||||
...mockUser,
|
|
||||||
name: 'new_name',
|
|
||||||
avatar: 'new_avatar',
|
|
||||||
});
|
|
||||||
await sessionRequest.post(`${signInRoute}/auth`).send({
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
data: {
|
|
||||||
state: 'state',
|
|
||||||
redirectUri: 'https://logto.dev',
|
|
||||||
code: '123456',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(updateUserById).not.toHaveBeenCalledWith(mockUser.id, {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
identities: expect.anything(),
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
|
||||||
lastSignInAt: expect.anything(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /session/sign-in/bind-social-related-user', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const mockGetLogtoConnectorById = getLogtoConnectorById as jest.Mock;
|
|
||||||
mockGetLogtoConnectorById.mockResolvedValueOnce({
|
|
||||||
metadata: { target: 'connectorTarget' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('throw if session is not authorized', async () => {
|
|
||||||
await expect(
|
|
||||||
sessionRequest
|
|
||||||
.post('/session/sign-in/bind-social-related-user')
|
|
||||||
.send({ connectorId: 'connectorId' })
|
|
||||||
).resolves.toHaveProperty('statusCode', 400);
|
|
||||||
});
|
|
||||||
it('throw if no social info in session', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: { login: { accountId: 'user1' } },
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
sessionRequest
|
|
||||||
.post('/session/sign-in/bind-social-related-user')
|
|
||||||
.send({ connectorId: 'connectorId' })
|
|
||||||
).resolves.toHaveProperty('statusCode', 400);
|
|
||||||
});
|
|
||||||
it('throw error when user is suspended', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
login: { accountId: 'user1' },
|
|
||||||
socialUserInfo: {
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
userInfo: { id: 'connectorUser', phone: 'phone' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
findSocialRelatedUser.mockResolvedValueOnce([
|
|
||||||
'phone',
|
|
||||||
{
|
|
||||||
...mockUser,
|
|
||||||
isSuspended: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const response = await sessionRequest.post('/session/sign-in/bind-social-related-user').send({
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(401);
|
|
||||||
});
|
|
||||||
it('updates user identities and sign in', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({
|
|
||||||
result: {
|
|
||||||
login: { accountId: 'user1' },
|
|
||||||
socialUserInfo: {
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
userInfo: { id: 'connectorUser', phone: 'phone' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const response = await sessionRequest.post('/session/sign-in/bind-social-related-user').send({
|
|
||||||
connectorId: 'connectorId',
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toEqual(200);
|
|
||||||
expect(updateUserById).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({
|
|
||||||
identities: {
|
|
||||||
connectorTarget: {
|
|
||||||
details: { id: 'connectorUser', phone: 'phone' },
|
|
||||||
userId: 'connectorUser',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(response.body).toHaveProperty('redirectTo');
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: 'user1' } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,275 +0,0 @@
|
||||||
import type { ConnectorSession } from '@logto/connector-kit';
|
|
||||||
import { validateRedirectUrl } from '@logto/core-kit';
|
|
||||||
import { ConnectorType, userInfoSelectFields } from '@logto/schemas';
|
|
||||||
import { conditional, pick } from '@silverhand/essentials';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
import { object, string, unknown } from 'zod';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import { getLogtoConnectorById } from '#src/libraries/connector.js';
|
|
||||||
import {
|
|
||||||
assignInteractionResults,
|
|
||||||
getApplicationIdFromInteraction,
|
|
||||||
} from '#src/libraries/session.js';
|
|
||||||
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
|
||||||
import {
|
|
||||||
findSocialRelatedUser,
|
|
||||||
getUserInfoByAuthCode,
|
|
||||||
getUserInfoFromInteractionResult,
|
|
||||||
} from '#src/libraries/social.js';
|
|
||||||
import { generateUserId, insertUser } from '#src/libraries/user.js';
|
|
||||||
import koaGuard from '#src/middleware/koa-guard.js';
|
|
||||||
import {
|
|
||||||
hasUserWithIdentity,
|
|
||||||
findUserById,
|
|
||||||
updateUserById,
|
|
||||||
findUserByIdentity,
|
|
||||||
} from '#src/queries/user.js';
|
|
||||||
import {
|
|
||||||
assignConnectorSessionResult,
|
|
||||||
getConnectorSessionResult,
|
|
||||||
} from '#src/routes/interaction/utils/interaction.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
import { maskUserInfo } from '#src/utils/format.js';
|
|
||||||
|
|
||||||
import type { AnonymousRouterLegacy } from '../types.js';
|
|
||||||
import { checkRequiredProfile, getRoutePrefix } from './utils.js';
|
|
||||||
|
|
||||||
export const registerRoute = getRoutePrefix('register', 'social');
|
|
||||||
export const signInRoute = getRoutePrefix('sign-in', 'social');
|
|
||||||
|
|
||||||
export default function socialRoutes<T extends AnonymousRouterLegacy>(
|
|
||||||
router: T,
|
|
||||||
provider: Provider
|
|
||||||
) {
|
|
||||||
router.post(
|
|
||||||
`${signInRoute}`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
connectorId: string(),
|
|
||||||
state: string(),
|
|
||||||
redirectUri: string().refine((url) => validateRedirectUrl(url, 'web')),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const {
|
|
||||||
headers: { 'user-agent': userAgent },
|
|
||||||
} = ctx.request;
|
|
||||||
await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
const { connectorId, state, redirectUri } = ctx.guard.body;
|
|
||||||
assertThat(state && redirectUri, 'session.insufficient_info');
|
|
||||||
const connector = await getLogtoConnectorById(connectorId);
|
|
||||||
assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type');
|
|
||||||
const redirectTo = await connector.getAuthorizationUri(
|
|
||||||
{ state, redirectUri, headers: { userAgent } },
|
|
||||||
async (connectorStorage: ConnectorSession) =>
|
|
||||||
assignConnectorSessionResult(ctx, provider, connectorStorage)
|
|
||||||
);
|
|
||||||
ctx.body = { redirectTo };
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
`${signInRoute}/auth`,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
connectorId: string(),
|
|
||||||
data: unknown(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
const { connectorId, data } = ctx.guard.body;
|
|
||||||
const type = 'SignInSocial';
|
|
||||||
ctx.log(type, { connectorId, data });
|
|
||||||
const {
|
|
||||||
metadata: { target },
|
|
||||||
dbEntry: { syncProfile },
|
|
||||||
} = await getLogtoConnectorById(connectorId);
|
|
||||||
|
|
||||||
const userInfo = await getUserInfoByAuthCode(connectorId, data, async () =>
|
|
||||||
getConnectorSessionResult(ctx, provider)
|
|
||||||
);
|
|
||||||
ctx.log(type, { userInfo });
|
|
||||||
|
|
||||||
const user = await findUserByIdentity(target, userInfo.id);
|
|
||||||
|
|
||||||
// User with identity not found
|
|
||||||
if (!user) {
|
|
||||||
await assignInteractionResults(
|
|
||||||
ctx,
|
|
||||||
provider,
|
|
||||||
{ socialUserInfo: { connectorId, userInfo } },
|
|
||||||
true
|
|
||||||
);
|
|
||||||
const relatedInfo = await findSocialRelatedUser(userInfo);
|
|
||||||
|
|
||||||
throw new RequestError(
|
|
||||||
{
|
|
||||||
code: 'user.identity_not_exist',
|
|
||||||
status: 422,
|
|
||||||
},
|
|
||||||
relatedInfo && { relatedUser: maskUserInfo(relatedInfo[0]) }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { id, identities, isSuspended } = user;
|
|
||||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
|
||||||
ctx.log(type, { userId: id });
|
|
||||||
|
|
||||||
const { name, avatar } = userInfo;
|
|
||||||
const profileUpdate = Object.fromEntries(
|
|
||||||
Object.entries({
|
|
||||||
name: conditional(syncProfile && name),
|
|
||||||
avatar: conditional(syncProfile && avatar),
|
|
||||||
}).filter(([_key, value]) => value !== undefined)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update social connector's user info
|
|
||||||
await updateUserById(id, {
|
|
||||||
identities: { ...identities, [target]: { userId: userInfo.id, details: userInfo } },
|
|
||||||
lastSignInAt: Date.now(),
|
|
||||||
...profileUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/session/sign-in/bind-social-related-user',
|
|
||||||
koaGuard({
|
|
||||||
body: object({ connectorId: string() }),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
assertThat(result, 'session.connector_session_not_found');
|
|
||||||
|
|
||||||
const { connectorId } = ctx.guard.body;
|
|
||||||
const type = 'SignInSocialBind';
|
|
||||||
ctx.log(type, { connectorId });
|
|
||||||
const {
|
|
||||||
metadata: { target },
|
|
||||||
} = await getLogtoConnectorById(connectorId);
|
|
||||||
|
|
||||||
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
|
|
||||||
ctx.log(type, { userInfo });
|
|
||||||
|
|
||||||
const relatedInfo = await findSocialRelatedUser(userInfo);
|
|
||||||
assertThat(relatedInfo, 'session.connector_session_not_found');
|
|
||||||
|
|
||||||
const { id, identities, isSuspended } = relatedInfo[1];
|
|
||||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
|
||||||
ctx.log(type, { userId: id });
|
|
||||||
|
|
||||||
const user = await updateUserById(id, {
|
|
||||||
identities: { ...identities, [target]: { userId: userInfo.id, details: userInfo } },
|
|
||||||
lastSignInAt: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
registerRoute,
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
connectorId: string(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
// User can not register with social directly,
|
|
||||||
// need to try to sign in with social first, then confirm to register and continue,
|
|
||||||
// so the result is expected to be exists.
|
|
||||||
assertThat(result, 'session.connector_session_not_found');
|
|
||||||
|
|
||||||
const { connectorId } = ctx.guard.body;
|
|
||||||
const type = 'RegisterSocial';
|
|
||||||
ctx.log(type, { connectorId });
|
|
||||||
const {
|
|
||||||
metadata: { target },
|
|
||||||
} = await getLogtoConnectorById(connectorId);
|
|
||||||
|
|
||||||
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
|
|
||||||
ctx.log(type, { userInfo });
|
|
||||||
assertThat(!(await hasUserWithIdentity(target, userInfo.id)), 'user.identity_already_in_use');
|
|
||||||
|
|
||||||
const id = await generateUserId();
|
|
||||||
const user = await insertUser({
|
|
||||||
id,
|
|
||||||
name: userInfo.name ?? null,
|
|
||||||
avatar: userInfo.avatar ?? null,
|
|
||||||
identities: {
|
|
||||||
[target]: {
|
|
||||||
userId: userInfo.id,
|
|
||||||
details: userInfo,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
lastSignInAt: Date.now(),
|
|
||||||
});
|
|
||||||
ctx.log(type, { userId: id });
|
|
||||||
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
await checkRequiredProfile(ctx, provider, user, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
'/session/bind-social',
|
|
||||||
koaGuard({
|
|
||||||
body: object({
|
|
||||||
connectorId: string(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
async (ctx, next) => {
|
|
||||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
assertThat(result, 'session.connector_session_not_found');
|
|
||||||
const userId = result.login?.accountId;
|
|
||||||
assertThat(userId, 'session.unauthorized');
|
|
||||||
|
|
||||||
const { connectorId } = ctx.guard.body;
|
|
||||||
const type = 'RegisterSocialBind';
|
|
||||||
ctx.log(type, { connectorId, userId });
|
|
||||||
const {
|
|
||||||
metadata: { target },
|
|
||||||
} = await getLogtoConnectorById(connectorId);
|
|
||||||
|
|
||||||
const userInfo = await getUserInfoFromInteractionResult(connectorId, result);
|
|
||||||
ctx.log(type, { userInfo });
|
|
||||||
|
|
||||||
const user = await findUserById(userId);
|
|
||||||
const updatedUser = await updateUserById(userId, {
|
|
||||||
identities: {
|
|
||||||
...user.identities,
|
|
||||||
[target]: { userId: userInfo.id, details: userInfo },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.body = pick(updatedUser, ...userInfoSelectFields);
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,89 +0,0 @@
|
||||||
import { VerificationCodeType } from '@logto/connector-kit';
|
|
||||||
import { pick } from '@silverhand/essentials';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const flowTypeGuard = z.nativeEnum(
|
|
||||||
pick(VerificationCodeType, 'Continue', 'ForgotPassword', 'Register', 'SignIn')
|
|
||||||
);
|
|
||||||
|
|
||||||
export const methodGuard = z.enum(['email', 'sms']);
|
|
||||||
|
|
||||||
export type Method = z.infer<typeof methodGuard>;
|
|
||||||
|
|
||||||
export const operationGuard = z.enum(['send', 'verify']);
|
|
||||||
|
|
||||||
export type Operation = z.infer<typeof operationGuard>;
|
|
||||||
|
|
||||||
const smsSessionStorageGuard = z.object({
|
|
||||||
flow: z.literal(VerificationCodeType.SignIn).or(z.literal(VerificationCodeType.Register)),
|
|
||||||
expiresAt: z.string(),
|
|
||||||
phone: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type SmsSessionStorage = z.infer<typeof smsSessionStorageGuard>;
|
|
||||||
|
|
||||||
export const smsSessionResultGuard = z.object({ verification: smsSessionStorageGuard });
|
|
||||||
|
|
||||||
const emailSessionStorageGuard = z.object({
|
|
||||||
flow: z.literal(VerificationCodeType.SignIn).or(z.literal(VerificationCodeType.Register)),
|
|
||||||
expiresAt: z.string(),
|
|
||||||
email: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type EmailSessionStorage = z.infer<typeof emailSessionStorageGuard>;
|
|
||||||
|
|
||||||
export const emailSessionResultGuard = z.object({
|
|
||||||
verification: emailSessionStorageGuard,
|
|
||||||
});
|
|
||||||
|
|
||||||
const forgotPasswordSessionStorageGuard = z.object({
|
|
||||||
flow: z.literal(VerificationCodeType.ForgotPassword),
|
|
||||||
expiresAt: z.string(),
|
|
||||||
userId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ForgotPasswordSessionStorage = z.infer<typeof forgotPasswordSessionStorageGuard>;
|
|
||||||
|
|
||||||
export const forgotPasswordSessionResultGuard = z.object({
|
|
||||||
verification: forgotPasswordSessionStorageGuard,
|
|
||||||
});
|
|
||||||
|
|
||||||
const continueEmailSessionStorageGuard = z.object({
|
|
||||||
flow: z.literal(VerificationCodeType.Continue),
|
|
||||||
expiresAt: z.string(),
|
|
||||||
email: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ContinueEmailSessionStorage = z.infer<typeof continueEmailSessionStorageGuard>;
|
|
||||||
|
|
||||||
export const continueEmailSessionResultGuard = z.object({
|
|
||||||
verification: continueEmailSessionStorageGuard,
|
|
||||||
});
|
|
||||||
|
|
||||||
const continueSmsSessionStorageGuard = z.object({
|
|
||||||
flow: z.literal(VerificationCodeType.Continue),
|
|
||||||
expiresAt: z.string(),
|
|
||||||
phone: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ContinueSmsSessionStorage = z.infer<typeof continueSmsSessionStorageGuard>;
|
|
||||||
|
|
||||||
export const continueSmsSessionResultGuard = z.object({
|
|
||||||
verification: continueSmsSessionStorageGuard,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type VerificationStorage =
|
|
||||||
| SmsSessionStorage
|
|
||||||
| EmailSessionStorage
|
|
||||||
| ForgotPasswordSessionStorage
|
|
||||||
| ContinueEmailSessionStorage
|
|
||||||
| ContinueSmsSessionStorage;
|
|
||||||
|
|
||||||
export type VerificationResult<T = VerificationStorage> = { verification: T };
|
|
||||||
|
|
||||||
export const continueSignInStorageGuard = z.object({
|
|
||||||
userId: z.string(),
|
|
||||||
expiresAt: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ContinueSignInStorage = z.infer<typeof continueSignInStorageGuard>;
|
|
|
@ -1,382 +0,0 @@
|
||||||
import type { User } from '@logto/schemas';
|
|
||||||
import { UserRole, SignInIdentifier } from '@logto/schemas';
|
|
||||||
import type { Nullable } from '@silverhand/essentials';
|
|
||||||
import Provider from 'oidc-provider';
|
|
||||||
|
|
||||||
import { mockSignInExperience, mockSignInMethod, mockUser } from '#src/__mocks__/index.js';
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
|
||||||
|
|
||||||
import { checkRequiredProfile, signInWithPassword } from './utils.js';
|
|
||||||
|
|
||||||
const insertUser = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
|
||||||
const findUserById = jest.fn(async (): Promise<User> => mockUser);
|
|
||||||
const updateUserById = jest.fn(async (..._args: unknown[]) => ({ id: 'id' }));
|
|
||||||
const hasActiveUsers = jest.fn(async () => true);
|
|
||||||
const findDefaultSignInExperience = jest.fn(async () => ({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Username],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/user.js', () => ({
|
|
||||||
findUserById: async () => findUserById(),
|
|
||||||
findUserByIdentity: async () => ({ id: 'id', identities: {} }),
|
|
||||||
findUserByPhone: async () => ({ id: 'id' }),
|
|
||||||
findUserByEmail: async () => ({ id: 'id' }),
|
|
||||||
updateUserById: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
hasUser: async (username: string) => username === 'username1',
|
|
||||||
hasUserWithIdentity: async (connectorId: string, userId: string) =>
|
|
||||||
connectorId === 'connectorId' && userId === 'id',
|
|
||||||
hasUserWithPhone: async (phone: string) => phone === '13000000000',
|
|
||||||
hasUserWithEmail: async (email: string) => email === 'a@a.com',
|
|
||||||
hasActiveUsers: async () => hasActiveUsers(),
|
|
||||||
async findUserByUsername(username: string) {
|
|
||||||
const roleNames = username === 'admin' ? [UserRole.Admin] : [];
|
|
||||||
|
|
||||||
return { id: 'user1', username, roleNames };
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/queries/sign-in-experience.js', () => ({
|
|
||||||
findDefaultSignInExperience: async () => findDefaultSignInExperience(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('#src/libraries/user.js', () => ({
|
|
||||||
async verifyUserPassword(user: Nullable<User>, password: string) {
|
|
||||||
if (!user) {
|
|
||||||
throw new RequestError('session.invalid_credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== 'password') {
|
|
||||||
throw new RequestError('session.invalid_credentials');
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
},
|
|
||||||
generateUserId: () => 'user1',
|
|
||||||
encryptUserPassword: (password: string) => ({
|
|
||||||
passwordEncrypted: password + '_user1',
|
|
||||||
passwordEncryptionMethod: 'Argon2i',
|
|
||||||
}),
|
|
||||||
updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args),
|
|
||||||
insertUser: async (...args: unknown[]) => insertUser(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const grantSave = jest.fn(async () => 'finalGrantId');
|
|
||||||
const grantAddOIDCScope = jest.fn();
|
|
||||||
const grantAddResourceScope = jest.fn();
|
|
||||||
const interactionResult = jest.fn(async () => 'redirectTo');
|
|
||||||
const interactionDetails: jest.MockedFunction<() => Promise<unknown>> = jest.fn(async () => ({}));
|
|
||||||
|
|
||||||
class Grant {
|
|
||||||
static async find(id: string) {
|
|
||||||
return id === 'exists' ? new Grant() : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
save: typeof grantSave;
|
|
||||||
addOIDCScope: typeof grantAddOIDCScope;
|
|
||||||
addResourceScope: typeof grantAddResourceScope;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.save = grantSave;
|
|
||||||
this.addOIDCScope = grantAddOIDCScope;
|
|
||||||
this.addResourceScope = grantAddResourceScope;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const createContext = () => ({
|
|
||||||
...createMockContext(),
|
|
||||||
addLogContext: jest.fn(),
|
|
||||||
log: jest.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createProvider = () => new Provider('');
|
|
||||||
|
|
||||||
jest.mock('oidc-provider', () => ({
|
|
||||||
Provider: jest.fn(() => ({
|
|
||||||
Grant,
|
|
||||||
interactionDetails,
|
|
||||||
interactionResult,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
grantSave.mockClear();
|
|
||||||
interactionResult.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkRequiredProfile', () => {
|
|
||||||
// eslint-disable-next-line @silverhand/fp/no-let
|
|
||||||
let mockDate: jest.SpyInstance;
|
|
||||||
const mockedExpiredAt = '2022-02-02';
|
|
||||||
beforeEach(() => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
|
||||||
mockDate = jest.spyOn(Date.prototype, 'toISOString').mockReturnValue(mockedExpiredAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
mockDate.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw if password is required but the user's password is not set", async () => {
|
|
||||||
const user = {
|
|
||||||
...mockUser,
|
|
||||||
passwordEncrypted: null,
|
|
||||||
passwordEncryptionMethod: null,
|
|
||||||
identities: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const signInExperience = {
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
password: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkRequiredProfile(createContext(), createProvider(), user, signInExperience)
|
|
||||||
).rejects.toThrowError(
|
|
||||||
new RequestError({ code: 'user.password_required_in_profile', status: 422 })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw if the sign up identifier is ['username'] but the user's username is missing", async () => {
|
|
||||||
const user = {
|
|
||||||
...mockUser,
|
|
||||||
username: null,
|
|
||||||
};
|
|
||||||
const signInExperience = {
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Username],
|
|
||||||
password: true,
|
|
||||||
verify: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkRequiredProfile(createContext(), createProvider(), user, signInExperience)
|
|
||||||
).rejects.toThrowError(
|
|
||||||
new RequestError({ code: 'user.username_required_in_profile', status: 422 })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw if the sign up identifier is ['email'] but the user's email is missing", async () => {
|
|
||||||
const user = {
|
|
||||||
...mockUser,
|
|
||||||
primaryEmail: null,
|
|
||||||
};
|
|
||||||
const signInExperience = {
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Email],
|
|
||||||
password: true,
|
|
||||||
verify: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkRequiredProfile(createContext(), createProvider(), user, signInExperience)
|
|
||||||
).rejects.toThrowError(
|
|
||||||
new RequestError({ code: 'user.email_required_in_profile', status: 422 })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw if the sign up identifier is ['sms'] but the user's phone is missing", async () => {
|
|
||||||
const user = {
|
|
||||||
...mockUser,
|
|
||||||
primaryPhone: null,
|
|
||||||
};
|
|
||||||
const signInExperience = {
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Phone],
|
|
||||||
password: true,
|
|
||||||
verify: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkRequiredProfile(createContext(), createProvider(), user, signInExperience)
|
|
||||||
).rejects.toThrowError(
|
|
||||||
new RequestError({ code: 'user.phone_required_in_profile', status: 422 })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throw if the sign up identifier is ['email', 'sms'] but the user's email and phone are missing", async () => {
|
|
||||||
const user = {
|
|
||||||
...mockUser,
|
|
||||||
primaryEmail: null,
|
|
||||||
primaryPhone: null,
|
|
||||||
};
|
|
||||||
const signInExperience = {
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
|
||||||
password: true,
|
|
||||||
verify: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkRequiredProfile(createContext(), createProvider(), user, signInExperience)
|
|
||||||
).rejects.toThrowError(
|
|
||||||
new RequestError({ code: 'user.email_or_phone_required_in_profile', status: 422 })
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ continueSignIn: { userId: user.id, expiresAt: mockedExpiredAt } })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([{ primaryEmail: null }, { primaryPhone: null }])(
|
|
||||||
"check successfully if the sign up identifier is ['email', 'sms'] and the user has an email or phone",
|
|
||||||
async (userProfile) => {
|
|
||||||
const user = {
|
|
||||||
...mockUser,
|
|
||||||
...userProfile,
|
|
||||||
};
|
|
||||||
const signInExperience = {
|
|
||||||
...mockSignInExperience,
|
|
||||||
signUp: {
|
|
||||||
...mockSignInExperience.signUp,
|
|
||||||
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
|
|
||||||
password: true,
|
|
||||||
verify: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkRequiredProfile(createContext(), createProvider(), user, signInExperience)
|
|
||||||
).resolves.not.toThrow();
|
|
||||||
|
|
||||||
expect(interactionResult).not.toBeCalled();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('signInWithPassword()', () => {
|
|
||||||
it('assign result', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
await signInWithPassword(createContext(), createProvider(), {
|
|
||||||
identifier: SignInIdentifier.Username,
|
|
||||||
password: 'password',
|
|
||||||
findUser: jest.fn(async () => mockUser),
|
|
||||||
logType: 'SignInUsernamePassword',
|
|
||||||
logPayload: { username: 'username' },
|
|
||||||
});
|
|
||||||
expect(interactionResult).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
expect.anything(),
|
|
||||||
expect.objectContaining({ login: { accountId: mockUser.id } }),
|
|
||||||
expect.anything()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw if user not found', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
await expect(
|
|
||||||
signInWithPassword(createContext(), createProvider(), {
|
|
||||||
identifier: SignInIdentifier.Username,
|
|
||||||
password: 'password',
|
|
||||||
findUser: jest.fn(async () => null),
|
|
||||||
logType: 'SignInUsernamePassword',
|
|
||||||
logPayload: { username: 'username' },
|
|
||||||
})
|
|
||||||
).rejects.toThrowError(new RequestError('session.invalid_credentials'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw if user found but wrong password', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
await expect(
|
|
||||||
signInWithPassword(createContext(), createProvider(), {
|
|
||||||
identifier: SignInIdentifier.Username,
|
|
||||||
password: '_password',
|
|
||||||
findUser: jest.fn(async () => mockUser),
|
|
||||||
logType: 'SignInUsernamePassword',
|
|
||||||
logPayload: { username: 'username' },
|
|
||||||
})
|
|
||||||
).rejects.toThrowError(new RequestError('session.invalid_credentials'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw if user is suspended', async () => {
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
await expect(
|
|
||||||
signInWithPassword(createContext(), createProvider(), {
|
|
||||||
identifier: SignInIdentifier.Username,
|
|
||||||
password: 'password',
|
|
||||||
findUser: jest.fn(async () => ({
|
|
||||||
...mockUser,
|
|
||||||
isSuspended: true,
|
|
||||||
})),
|
|
||||||
logType: 'SignInUsernamePassword',
|
|
||||||
logPayload: { username: 'username' },
|
|
||||||
})
|
|
||||||
).rejects.toThrowError(new RequestError('user.suspended'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throw if sign in method is not enabled', async () => {
|
|
||||||
findDefaultSignInExperience.mockResolvedValueOnce({
|
|
||||||
...mockSignInExperience,
|
|
||||||
signIn: {
|
|
||||||
methods: [
|
|
||||||
{
|
|
||||||
...mockSignInMethod,
|
|
||||||
identifier: SignInIdentifier.Phone,
|
|
||||||
password: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
interactionDetails.mockResolvedValueOnce({ params: {} });
|
|
||||||
await expect(
|
|
||||||
signInWithPassword(createContext(), createProvider(), {
|
|
||||||
identifier: SignInIdentifier.Username,
|
|
||||||
password: 'password',
|
|
||||||
findUser: jest.fn(async () => mockUser),
|
|
||||||
logType: 'SignInUsernamePassword',
|
|
||||||
logPayload: { username: 'username' },
|
|
||||||
})
|
|
||||||
).rejects.toThrowError(
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.sign_in_method_not_enabled',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,247 +0,0 @@
|
||||||
import type { VerificationCodeType } from '@logto/connector-kit';
|
|
||||||
import type { SignInExperience, User } from '@logto/schemas';
|
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
|
||||||
import type { LogPayload, LogType } from '@logto/schemas/lib/types/log-legacy.js';
|
|
||||||
import { logTypeGuard } from '@logto/schemas/lib/types/log-legacy.js';
|
|
||||||
import type { Nullable, Truthy } from '@silverhand/essentials';
|
|
||||||
import { isSameArray } from '@silverhand/essentials';
|
|
||||||
import { addSeconds, isAfter, isValid } from 'date-fns';
|
|
||||||
import type { Context } from 'koa';
|
|
||||||
import type Provider from 'oidc-provider';
|
|
||||||
import type { ZodType } from 'zod';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import RequestError from '#src/errors/RequestError/index.js';
|
|
||||||
import {
|
|
||||||
assignInteractionResults,
|
|
||||||
getApplicationIdFromInteraction,
|
|
||||||
} from '#src/libraries/session.js';
|
|
||||||
import { getSignInExperienceForApplication } from '#src/libraries/sign-in-experience/index.js';
|
|
||||||
import { verifyUserPassword } from '#src/libraries/user.js';
|
|
||||||
import type { LogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js';
|
|
||||||
import { updateUserById } from '#src/queries/user.js';
|
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
|
||||||
|
|
||||||
import { continueSignInTimeout, verificationTimeout } from '../consts.js';
|
|
||||||
import type { Method, Operation, VerificationResult, VerificationStorage } from './types.js';
|
|
||||||
import { continueSignInStorageGuard } from './types.js';
|
|
||||||
|
|
||||||
export const getRoutePrefix = (
|
|
||||||
type: 'sign-in' | 'register' | 'forgot-password',
|
|
||||||
method?: 'passwordless' | 'password' | 'social' | 'continue'
|
|
||||||
) => {
|
|
||||||
return ['session', type, method]
|
|
||||||
.filter((value): value is Truthy<typeof value> => value !== undefined)
|
|
||||||
.map((value) => '/' + value)
|
|
||||||
.join('');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPasswordlessRelatedLogType = (
|
|
||||||
flow: VerificationCodeType,
|
|
||||||
method: Method,
|
|
||||||
operation?: Operation
|
|
||||||
): LogType => {
|
|
||||||
const body = method === 'email' ? 'Email' : 'Sms';
|
|
||||||
const suffix = operation === 'send' ? 'SendPasscode' : '';
|
|
||||||
|
|
||||||
const result = logTypeGuard.safeParse(flow + body + suffix);
|
|
||||||
assertThat(result.success, new RequestError('log.invalid_type'));
|
|
||||||
|
|
||||||
return result.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getVerificationStorageFromInteraction = async <T = VerificationStorage>(
|
|
||||||
ctx: Context,
|
|
||||||
provider: Provider,
|
|
||||||
resultGuard: ZodType<VerificationResult<T>>
|
|
||||||
): Promise<T> => {
|
|
||||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
const verificationResult = resultGuard.safeParse(result);
|
|
||||||
|
|
||||||
if (!verificationResult.success) {
|
|
||||||
throw new RequestError(
|
|
||||||
{
|
|
||||||
code: 'session.verification_session_not_found',
|
|
||||||
status: 404,
|
|
||||||
},
|
|
||||||
verificationResult.error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return verificationResult.data.verification;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const checkValidateExpiration = (expiresAt: string) => {
|
|
||||||
const parsed = new Date(expiresAt);
|
|
||||||
assertThat(
|
|
||||||
isValid(parsed) && isAfter(parsed, Date.now()),
|
|
||||||
new RequestError({ code: 'session.verification_expired', status: 401 })
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
|
||||||
|
|
||||||
export const assignVerificationResult = async (
|
|
||||||
ctx: Context,
|
|
||||||
provider: Provider,
|
|
||||||
verificationData: DistributiveOmit<VerificationStorage, 'expiresAt'>
|
|
||||||
) => {
|
|
||||||
const verification: VerificationStorage = {
|
|
||||||
...verificationData,
|
|
||||||
expiresAt: addSeconds(Date.now(), verificationTimeout).toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const details = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
await provider.interactionResult(ctx.req, ctx.res, {
|
|
||||||
...details.result,
|
|
||||||
verification,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearVerificationResult = async (ctx: Context, provider: Provider) => {
|
|
||||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
const verificationGuard = z.object({ verification: z.unknown() });
|
|
||||||
const verificationGuardResult = verificationGuard.safeParse(result);
|
|
||||||
|
|
||||||
if (result && verificationGuardResult.success) {
|
|
||||||
const { verification, ...rest } = result;
|
|
||||||
await provider.interactionResult(ctx.req, ctx.res, rest);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const assignContinueSignInResult = async (
|
|
||||||
ctx: Context,
|
|
||||||
provider: Provider,
|
|
||||||
payload: { userId: string }
|
|
||||||
) => {
|
|
||||||
const details = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
await provider.interactionResult(ctx.req, ctx.res, {
|
|
||||||
...details.result,
|
|
||||||
continueSignIn: {
|
|
||||||
...payload,
|
|
||||||
expiresAt: addSeconds(Date.now(), continueSignInTimeout).toISOString(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getContinueSignInResult = async (
|
|
||||||
ctx: Context,
|
|
||||||
provider: Provider
|
|
||||||
): Promise<{ userId: string }> => {
|
|
||||||
const { result } = await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
|
|
||||||
const signInResult = z
|
|
||||||
.object({
|
|
||||||
continueSignIn: continueSignInStorageGuard,
|
|
||||||
})
|
|
||||||
.safeParse(result);
|
|
||||||
|
|
||||||
if (!signInResult.success) {
|
|
||||||
throw new RequestError({
|
|
||||||
code: 'session.unauthorized',
|
|
||||||
status: 401,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { expiresAt, ...rest } = signInResult.data.continueSignIn;
|
|
||||||
|
|
||||||
const parsed = new Date(expiresAt);
|
|
||||||
assertThat(
|
|
||||||
isValid(parsed) && isAfter(parsed, Date.now()),
|
|
||||||
new RequestError({ code: 'session.unauthorized', status: 401 })
|
|
||||||
);
|
|
||||||
|
|
||||||
return rest;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isUserPasswordSet = ({
|
|
||||||
passwordEncrypted,
|
|
||||||
identities,
|
|
||||||
}: Pick<User, 'passwordEncrypted' | 'identities'>): boolean => {
|
|
||||||
return Boolean(passwordEncrypted) || Object.keys(identities).length > 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* eslint-disable complexity */
|
|
||||||
export const checkRequiredProfile = async (
|
|
||||||
ctx: Context,
|
|
||||||
provider: Provider,
|
|
||||||
user: User,
|
|
||||||
signInExperience: SignInExperience
|
|
||||||
) => {
|
|
||||||
const { signUp } = signInExperience;
|
|
||||||
const { id, username, primaryEmail, primaryPhone } = user;
|
|
||||||
|
|
||||||
// If check failed, save the sign in result, the user can continue after requirements are meet
|
|
||||||
|
|
||||||
if (signUp.password && !isUserPasswordSet(user)) {
|
|
||||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
|
||||||
throw new RequestError({ code: 'user.password_required_in_profile', status: 422 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSameArray(signUp.identifiers, [SignInIdentifier.Username]) && !username) {
|
|
||||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
|
||||||
throw new RequestError({ code: 'user.username_required_in_profile', status: 422 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSameArray(signUp.identifiers, [SignInIdentifier.Email]) && !primaryEmail) {
|
|
||||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
|
||||||
throw new RequestError({ code: 'user.email_required_in_profile', status: 422 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSameArray(signUp.identifiers, [SignInIdentifier.Phone]) && !primaryPhone) {
|
|
||||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
|
||||||
throw new RequestError({ code: 'user.phone_required_in_profile', status: 422 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
isSameArray(signUp.identifiers, [SignInIdentifier.Email, SignInIdentifier.Phone]) &&
|
|
||||||
!primaryEmail &&
|
|
||||||
!primaryPhone
|
|
||||||
) {
|
|
||||||
await assignContinueSignInResult(ctx, provider, { userId: id });
|
|
||||||
throw new RequestError({ code: 'user.email_or_phone_required_in_profile', status: 422 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
/* eslint-enable complexity */
|
|
||||||
|
|
||||||
type SignInWithPasswordParameter = {
|
|
||||||
identifier: SignInIdentifier;
|
|
||||||
password: string;
|
|
||||||
logType: LogType;
|
|
||||||
logPayload: LogPayload;
|
|
||||||
findUser: () => Promise<Nullable<User>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const signInWithPassword = async (
|
|
||||||
ctx: Context & LogContextLegacy,
|
|
||||||
provider: Provider,
|
|
||||||
{ identifier, findUser, password, logType, logPayload }: SignInWithPasswordParameter
|
|
||||||
) => {
|
|
||||||
const signInExperience = await getSignInExperienceForApplication(
|
|
||||||
await getApplicationIdFromInteraction(ctx, provider)
|
|
||||||
);
|
|
||||||
assertThat(
|
|
||||||
signInExperience.signIn.methods.some(
|
|
||||||
(method) => method.password && method.identifier === identifier
|
|
||||||
),
|
|
||||||
new RequestError({
|
|
||||||
code: 'user.sign_in_method_not_enabled',
|
|
||||||
status: 422,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await provider.interactionDetails(ctx.req, ctx.res);
|
|
||||||
ctx.log(logType, logPayload);
|
|
||||||
|
|
||||||
const user = await findUser();
|
|
||||||
const verifiedUser = await verifyUserPassword(user, password);
|
|
||||||
const { id, isSuspended } = verifiedUser;
|
|
||||||
assertThat(!isSuspended, new RequestError({ code: 'user.suspended', status: 401 }));
|
|
||||||
|
|
||||||
ctx.log(logType, { userId: id });
|
|
||||||
await updateUserById(id, { lastSignInAt: Date.now() });
|
|
||||||
await checkRequiredProfile(ctx, provider, verifiedUser, signInExperience);
|
|
||||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } }, true);
|
|
||||||
};
|
|
|
@ -1,7 +1,6 @@
|
||||||
import type { ExtendableContext } from 'koa';
|
import type { ExtendableContext } from 'koa';
|
||||||
import type Router from 'koa-router';
|
import type Router from 'koa-router';
|
||||||
|
|
||||||
import type { WithLogContextLegacy } from '#src/middleware/koa-audit-log-legacy.js';
|
|
||||||
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
|
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
|
||||||
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
|
import type { WithI18nContext } from '#src/middleware/koa-i18next.js';
|
||||||
|
@ -9,9 +8,6 @@ import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
|
|
||||||
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
|
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
|
||||||
|
|
||||||
/** @deprecated This will be removed soon. Use `kua-log-session.js` instead. */
|
|
||||||
export type AnonymousRouterLegacy = Router<unknown, WithLogContextLegacy & WithI18nContext>;
|
|
||||||
|
|
||||||
export type AuthedRouter = Router<
|
export type AuthedRouter = Router<
|
||||||
unknown,
|
unknown,
|
||||||
WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext
|
WithAuthContext & WithLogContext & WithI18nContext & ExtendableContext
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
import api from './api';
|
|
||||||
|
|
||||||
export const bindSocialAccount = async (connectorId: string) => {
|
|
||||||
return api
|
|
||||||
.post('/api/session/bind-social', {
|
|
||||||
json: {
|
|
||||||
connectorId,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.json();
|
|
||||||
};
|
|
Loading…
Reference in a new issue