mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(core,schemas): save application id that the user first consented (#688)
* feat(core,schemas): save application id which the user first consented * chore(core): fix grammatical mistake and typos
This commit is contained in:
parent
26332f8644
commit
4521c3c8d1
10 changed files with 101 additions and 19 deletions
|
@ -16,6 +16,7 @@ export const mockUser: User = {
|
|||
connector1: { userId: 'connector1', details: {} },
|
||||
},
|
||||
customData: {},
|
||||
applicationId: 'bar',
|
||||
};
|
||||
|
||||
export const mockUserResponse = pick(mockUser, ...userInfoSelectFields);
|
||||
|
@ -34,6 +35,7 @@ export const mockUserList: User[] = [
|
|||
avatar: null,
|
||||
identities: {},
|
||||
customData: {},
|
||||
applicationId: 'bar',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
|
@ -48,6 +50,7 @@ export const mockUserList: User[] = [
|
|||
avatar: null,
|
||||
identities: {},
|
||||
customData: {},
|
||||
applicationId: 'bar',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
|
@ -62,6 +65,7 @@ export const mockUserList: User[] = [
|
|||
avatar: null,
|
||||
identities: {},
|
||||
customData: {},
|
||||
applicationId: 'bar',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
|
@ -76,6 +80,7 @@ export const mockUserList: User[] = [
|
|||
avatar: null,
|
||||
identities: {},
|
||||
customData: {},
|
||||
applicationId: 'bar',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
|
@ -90,6 +95,7 @@ export const mockUserList: User[] = [
|
|||
avatar: null,
|
||||
identities: {},
|
||||
customData: {},
|
||||
applicationId: 'bar',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ const buildExpectedInsertIntoSql = (keys: string[]) => [
|
|||
|
||||
describe('buildInsertInto()', () => {
|
||||
it('resolves a promise with `undefined` when `returning` is false', async () => {
|
||||
const user: CreateUser = { id: 'foo', username: '456' };
|
||||
const user: CreateUser = { id: 'foo', username: '456', applicationId: 'bar' };
|
||||
const expectInsertIntoSql = buildExpectedInsertIntoSql(Object.keys(user));
|
||||
const pool = createTestPool(expectInsertIntoSql.join('\n'));
|
||||
poolSpy.mockReturnValue(pool);
|
||||
|
@ -28,7 +28,12 @@ describe('buildInsertInto()', () => {
|
|||
});
|
||||
|
||||
it('resolves a promise with `undefined` when `returning` is false and `onConflict` is enabled', async () => {
|
||||
const user: CreateUser = { id: 'foo', username: '456', primaryEmail: 'foo@bar.com' };
|
||||
const user: CreateUser = {
|
||||
id: 'foo',
|
||||
username: '456',
|
||||
primaryEmail: 'foo@bar.com',
|
||||
applicationId: 'bar',
|
||||
};
|
||||
const expectInsertIntoSql = buildExpectedInsertIntoSql(Object.keys(user));
|
||||
const pool = createTestPool(
|
||||
[
|
||||
|
@ -50,32 +55,48 @@ describe('buildInsertInto()', () => {
|
|||
});
|
||||
|
||||
it('resolves a promise with single entity when `returning` is true', async () => {
|
||||
const user: CreateUser = { id: 'foo', username: '123', primaryEmail: 'foo@bar.com' };
|
||||
const user: CreateUser = {
|
||||
id: 'foo',
|
||||
username: '123',
|
||||
primaryEmail: 'foo@bar.com',
|
||||
applicationId: 'bar',
|
||||
};
|
||||
const expectInsertIntoSql = buildExpectedInsertIntoSql(Object.keys(user));
|
||||
const pool = createTestPool(
|
||||
[...expectInsertIntoSql, 'returning *'].join('\n'),
|
||||
(_, [id, username, primaryEmail]) => ({
|
||||
(_, [id, username, primaryEmail, applicationId]) => ({
|
||||
id: String(id),
|
||||
username: String(username),
|
||||
primaryEmail: String(primaryEmail),
|
||||
applicationId: String(applicationId),
|
||||
})
|
||||
);
|
||||
poolSpy.mockReturnValue(pool);
|
||||
|
||||
const insertInto = buildInsertInto(Users, { returning: true });
|
||||
await expect(
|
||||
insertInto({ id: 'foo', username: '123', primaryEmail: 'foo@bar.com' })
|
||||
insertInto({ id: 'foo', username: '123', primaryEmail: 'foo@bar.com', applicationId: 'bar' })
|
||||
).resolves.toStrictEqual(user);
|
||||
});
|
||||
|
||||
it('throws `InsertionError` error when `returning` is true', async () => {
|
||||
const user: CreateUser = { id: 'foo', username: '123', primaryEmail: 'foo@bar.com' };
|
||||
const user: CreateUser = {
|
||||
id: 'foo',
|
||||
username: '123',
|
||||
primaryEmail: 'foo@bar.com',
|
||||
applicationId: 'bar',
|
||||
};
|
||||
const expectInsertIntoSql = buildExpectedInsertIntoSql(Object.keys(user));
|
||||
const pool = createTestPool([...expectInsertIntoSql, 'returning *'].join('\n'));
|
||||
poolSpy.mockReturnValue(pool);
|
||||
|
||||
const insertInto = buildInsertInto(Users, { returning: true });
|
||||
const dataToInsert = { id: 'foo', username: '123', primaryEmail: 'foo@bar.com' };
|
||||
const dataToInsert = {
|
||||
id: 'foo',
|
||||
username: '123',
|
||||
primaryEmail: 'foo@bar.com',
|
||||
applicationId: 'bar',
|
||||
};
|
||||
|
||||
await expect(insertInto(dataToInsert)).rejects.toMatchError(
|
||||
new InsertionError(Users, dataToInsert)
|
||||
|
|
|
@ -25,20 +25,29 @@ describe('buildUpdateWhere()', () => {
|
|||
});
|
||||
|
||||
it('resolves a promise with single entity when `returning` is true', async () => {
|
||||
const user: CreateUser = { id: 'foo', username: '123', primaryEmail: 'foo@bar.com' };
|
||||
const user: CreateUser = {
|
||||
id: 'foo',
|
||||
username: '123',
|
||||
primaryEmail: 'foo@bar.com',
|
||||
applicationId: 'bar',
|
||||
};
|
||||
const pool = createTestPool(
|
||||
'update "users"\nset "username"=$1, "primary_email"=$2\nwhere "id"=$3\nreturning *',
|
||||
(_, [username, primaryEmail, id]) => ({
|
||||
'update "users"\nset "username"=$1, "primary_email"=$2, "application_id"=$3\nwhere "id"=$4\nreturning *',
|
||||
(_, [username, primaryEmail, applicationId, id]) => ({
|
||||
id: String(id),
|
||||
username: String(username),
|
||||
primaryEmail: String(primaryEmail),
|
||||
applicationId: String(applicationId),
|
||||
})
|
||||
);
|
||||
poolSpy.mockReturnValue(pool);
|
||||
|
||||
const updateWhere = buildUpdateWhere(Users, true);
|
||||
await expect(
|
||||
updateWhere({ set: { username: '123', primaryEmail: 'foo@bar.com' }, where: { id: 'foo' } })
|
||||
updateWhere({
|
||||
set: { username: '123', primaryEmail: 'foo@bar.com', applicationId: 'bar' },
|
||||
where: { id: 'foo' },
|
||||
})
|
||||
).resolves.toStrictEqual(user);
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { Context } from 'koa';
|
||||
import { InteractionResults, Provider } from 'oidc-provider';
|
||||
|
||||
import { findUserById, updateUserById } from '@/queries/user';
|
||||
|
||||
export const assignInteractionResults = async (
|
||||
ctx: Context,
|
||||
provider: Provider,
|
||||
|
@ -12,3 +14,12 @@ export const assignInteractionResults = async (
|
|||
});
|
||||
ctx.body = { redirectTo };
|
||||
};
|
||||
|
||||
export const saveUserFirstConsentedAppId = async (userId: string, applicationId: string) => {
|
||||
const { applicationId: firstConsentedAppId } = await findUserById(userId);
|
||||
|
||||
if (!firstConsentedAppId) {
|
||||
// Save application id that the user first consented
|
||||
await updateUserById(userId, { applicationId });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -36,7 +36,7 @@ describe('koaSlonikErrorHandler middleware', () => {
|
|||
});
|
||||
|
||||
it('Insertion Error', async () => {
|
||||
const error = new InsertionError(Users, { id: '123' });
|
||||
const error = new InsertionError(Users, { id: '123', applicationId: 'bar' });
|
||||
next.mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
|
|
|
@ -75,7 +75,7 @@ describe('postgres Adapter', () => {
|
|||
const uid = 'fooUser';
|
||||
const userCode = 'fooCode';
|
||||
const id = 'fooId';
|
||||
const grantId = 'grandId';
|
||||
const grantId = 'grantId';
|
||||
const expireAt = 60;
|
||||
const adapter = postgresAdapter(modelName);
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
/* eslint-disable max-lines */
|
||||
import { User } from '@logto/schemas';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import { mockSignInExperience } from '@/__mocks__';
|
||||
import { mockSignInExperience, mockUser } from '@/__mocks__';
|
||||
import { ConnectorType } from '@/connectors/types';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import * as signInExperienceQueries from '@/queries/sign-in-experience';
|
||||
|
@ -52,9 +53,10 @@ jest.mock('@/lib/social', () => ({
|
|||
},
|
||||
}));
|
||||
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' }));
|
||||
jest.mock('@/queries/user', () => ({
|
||||
findUserById: async () => ({ id: 'id' }),
|
||||
findUserById: async () => findUserById(),
|
||||
findUserByIdentity: async () => ({ id: 'id', identities: {} }),
|
||||
findUserByPhone: async () => ({ id: 'id' }),
|
||||
findUserByEmail: async () => ({ id: 'id' }),
|
||||
|
@ -789,6 +791,10 @@ describe('sessionRoutes', () => {
|
|||
|
||||
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' },
|
||||
|
@ -826,6 +832,22 @@ describe('sessionRoutes', () => {
|
|||
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' },
|
||||
|
@ -856,7 +878,7 @@ describe('sessionRoutes', () => {
|
|||
});
|
||||
});
|
||||
it('throws if session is missing', async () => {
|
||||
interactionDetails.mockResolvedValueOnce({});
|
||||
interactionDetails.mockResolvedValueOnce({ params: { client_id: 'clientId' } });
|
||||
await expect(sessionRequest.post('/session/consent')).resolves.toHaveProperty(
|
||||
'statusCode',
|
||||
400
|
||||
|
|
|
@ -11,7 +11,7 @@ import { object, string } from 'zod';
|
|||
import { getSocialConnectorInstanceById } from '@/connectors';
|
||||
import RequestError from '@/errors/RequestError';
|
||||
import { createPasscode, sendPasscode, verifyPasscode } from '@/lib/passcode';
|
||||
import { assignInteractionResults } from '@/lib/session';
|
||||
import { assignInteractionResults, saveUserFirstConsentedAppId } from '@/lib/session';
|
||||
import {
|
||||
findSocialRelatedUser,
|
||||
getUserInfoByAuthCode,
|
||||
|
@ -264,13 +264,20 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
|
|||
|
||||
router.post('/session/consent', async (ctx, next) => {
|
||||
const interaction = await provider.interactionDetails(ctx.req, ctx.res);
|
||||
const { session, grantId, params, prompt } = interaction;
|
||||
const {
|
||||
session,
|
||||
grantId,
|
||||
params: { client_id },
|
||||
prompt,
|
||||
} = interaction;
|
||||
assertThat(session, 'session.not_found');
|
||||
|
||||
const { accountId } = session;
|
||||
const grant =
|
||||
conditional(grantId && (await provider.Grant.find(grantId))) ??
|
||||
new provider.Grant({ accountId, clientId: String(params.client_id) });
|
||||
new provider.Grant({ accountId, clientId: String(client_id) });
|
||||
|
||||
await saveUserFirstConsentedAppId(accountId, String(client_id));
|
||||
|
||||
// V2: fulfill missing claims / resources
|
||||
const PromptDetailsBody = object({
|
||||
|
|
|
@ -24,6 +24,7 @@ export type CreateUser = {
|
|||
passwordEncryptionSalt?: string | null;
|
||||
name?: string | null;
|
||||
avatar?: string | null;
|
||||
applicationId?: string | null;
|
||||
roleNames?: RoleNames;
|
||||
identities?: Identities;
|
||||
customData?: ArbitraryObject;
|
||||
|
@ -39,6 +40,7 @@ export type User = {
|
|||
passwordEncryptionSalt: string | null;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
applicationId: string | null;
|
||||
roleNames: RoleNames;
|
||||
identities: Identities;
|
||||
customData: ArbitraryObject;
|
||||
|
@ -54,6 +56,7 @@ const createGuard: Guard<CreateUser> = z.object({
|
|||
passwordEncryptionSalt: z.string().nullable().optional(),
|
||||
name: z.string().nullable().optional(),
|
||||
avatar: z.string().nullable().optional(),
|
||||
applicationId: z.string().nullable().optional(),
|
||||
roleNames: roleNamesGuard.optional(),
|
||||
identities: identitiesGuard.optional(),
|
||||
customData: arbitraryObjectGuard.optional(),
|
||||
|
@ -72,6 +75,7 @@ export const Users: GeneratedSchema<CreateUser> = Object.freeze({
|
|||
passwordEncryptionSalt: 'password_encryption_salt',
|
||||
name: 'name',
|
||||
avatar: 'avatar',
|
||||
applicationId: 'application_id',
|
||||
roleNames: 'role_names',
|
||||
identities: 'identities',
|
||||
customData: 'custom_data',
|
||||
|
@ -86,6 +90,7 @@ export const Users: GeneratedSchema<CreateUser> = Object.freeze({
|
|||
'passwordEncryptionSalt',
|
||||
'name',
|
||||
'avatar',
|
||||
'applicationId',
|
||||
'roleNames',
|
||||
'identities',
|
||||
'customData',
|
||||
|
|
|
@ -10,6 +10,7 @@ create table users (
|
|||
password_encryption_salt varchar(128),
|
||||
name varchar(128),
|
||||
avatar varchar(256),
|
||||
application_id varchar(21) references applications(id),
|
||||
role_names jsonb /* @use RoleNames */ not null default '[]'::jsonb,
|
||||
identities jsonb /* @use Identities */ not null default '{}'::jsonb,
|
||||
custom_data jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
|
||||
|
|
Loading…
Add table
Reference in a new issue