0
Fork 0
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:
IceHe.xyz 2022-05-05 14:57:20 +08:00 committed by GitHub
parent 26332f8644
commit 4521c3c8d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 101 additions and 19 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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