0
Fork 0
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:
simeng-li 2023-01-10 13:49:00 +08:00 committed by GitHub
parent 8b8103132e
commit 39f15acb40
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 2 additions and 4971 deletions

1
.github/CODEOWNERS vendored
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +0,0 @@
import api from './api';
export const bindSocialAccount = async (connectorId: string) => {
return api
.post('/api/session/bind-social', {
json: {
connectorId,
},
})
.json();
};