From 39ae8ba943e04ac4ede12effcc78e4011b7f0cfa Mon Sep 17 00:00:00 2001 From: wangsijie Date: Tue, 18 Jun 2024 13:54:59 +0800 Subject: [PATCH] feat(core): issue subject tokens --- packages/core/src/constants/index.ts | 4 ++ packages/core/src/queries/subject-token.ts | 14 +++++ packages/core/src/routes/init.ts | 2 + .../src/routes/security/index.openapi.json | 41 ++++++++++++++ packages/core/src/routes/security/index.ts | 56 +++++++++++++++++++ packages/core/src/routes/swagger/index.ts | 6 +- packages/core/src/tenants/Queries.ts | 2 + .../src/api/subject-token.ts | 13 +++++ .../src/tests/api/security.test.ts | 19 +++++++ .../next-1718865814-add-subject-tokens.ts | 36 ++++++++++++ packages/schemas/src/types/index.ts | 1 + packages/schemas/src/types/subject-token.ts | 8 +++ packages/schemas/tables/subject_tokens.sql | 16 ++++++ 13 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/queries/subject-token.ts create mode 100644 packages/core/src/routes/security/index.openapi.json create mode 100644 packages/core/src/routes/security/index.ts create mode 100644 packages/integration-tests/src/api/subject-token.ts create mode 100644 packages/integration-tests/src/tests/api/security.test.ts create mode 100644 packages/schemas/alterations/next-1718865814-add-subject-tokens.ts create mode 100644 packages/schemas/src/types/subject-token.ts create mode 100644 packages/schemas/tables/subject_tokens.sql diff --git a/packages/core/src/constants/index.ts b/packages/core/src/constants/index.ts index 546eb766c3b..f72b02237f8 100644 --- a/packages/core/src/constants/index.ts +++ b/packages/core/src/constants/index.ts @@ -1 +1,5 @@ export const protectedAppSignInCallbackUrl = 'sign-in-callback'; +/** The default lifetime of subject tokens (in seconds) */ +export const subjectTokenExpiresIn = 600; +/** The prefix for subject tokens */ +export const subjectTokenPrefix = 'sub_'; diff --git a/packages/core/src/queries/subject-token.ts b/packages/core/src/queries/subject-token.ts new file mode 100644 index 00000000000..653e9d9c364 --- /dev/null +++ b/packages/core/src/queries/subject-token.ts @@ -0,0 +1,14 @@ +import { SubjectTokens } from '@logto/schemas'; +import type { CommonQueryMethods } from '@silverhand/slonik'; + +import { buildInsertIntoWithPool } from '#src/database/insert-into.js'; + +export const createSubjectTokenQueries = (pool: CommonQueryMethods) => { + const insertSubjectToken = buildInsertIntoWithPool(pool)(SubjectTokens, { + returning: true, + }); + + return { + insertSubjectToken, + }; +}; diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index d5f25b16b30..b1e34613df9 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -32,6 +32,7 @@ import resourceRoutes from './resource.js'; import resourceScopeRoutes from './resource.scope.js'; import roleRoutes from './role.js'; import roleScopeRoutes from './role.scope.js'; +import securityRoutes from './security/index.js'; import signInExperiencesRoutes from './sign-in-experience/index.js'; import ssoConnectors from './sso-connector/index.js'; import statusRoutes from './status.js'; @@ -79,6 +80,7 @@ const createRouters = (tenant: TenantContext) => { organizationRoutes(managementRouter, tenant); ssoConnectors(managementRouter, tenant); systemRoutes(managementRouter, tenant); + securityRoutes(managementRouter, tenant); const anonymousRouter: AnonymousRouter = new Router(); wellKnownRoutes(anonymousRouter, tenant); diff --git a/packages/core/src/routes/security/index.openapi.json b/packages/core/src/routes/security/index.openapi.json new file mode 100644 index 00000000000..b575b14bceb --- /dev/null +++ b/packages/core/src/routes/security/index.openapi.json @@ -0,0 +1,41 @@ +{ + "tags": [ + { + "name": "Security", + "description": "Security related endpoints." + } + ], + "paths": { + "/api/security/subject-tokens": { + "post": { + "tags": ["Dev feature"], + "summary": "Create a new subject token.", + "description": "Create a new subject token for the use of impersonating the user.", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "userId": { + "description": "The ID of the user to impersonate." + }, + "context": { + "description": "The additional context to be included in the token, this can be used in custom JWT." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "The subject token has been created successfully." + }, + "404": { + "description": "The user does not exist." + } + } + } + } + } +} diff --git a/packages/core/src/routes/security/index.ts b/packages/core/src/routes/security/index.ts new file mode 100644 index 00000000000..13fc457d5e4 --- /dev/null +++ b/packages/core/src/routes/security/index.ts @@ -0,0 +1,56 @@ +import { jsonObjectGuard, subjectTokenResponseGuard } from '@logto/schemas'; +import { generateStandardId } from '@logto/shared'; +import { addSeconds } from 'date-fns'; +import { object, string } from 'zod'; + +import { subjectTokenExpiresIn, subjectTokenPrefix } from '#src/constants/index.js'; +import { EnvSet } from '#src/env-set/index.js'; +import koaGuard from '#src/middleware/koa-guard.js'; + +import { type RouterInitArgs, type ManagementApiRouter } from '../types.js'; + +export default function securityRoutes(...args: RouterInitArgs) { + const [router, { queries }] = args; + const { + subjectTokens: { insertSubjectToken }, + } = queries; + + if (!EnvSet.values.isDevFeaturesEnabled) { + return; + } + + router.post( + '/security/subject-tokens', + koaGuard({ + body: object({ + userId: string(), + context: jsonObjectGuard.optional(), + }), + response: subjectTokenResponseGuard, + status: [201, 404], + }), + async (ctx, next) => { + const { + auth: { id }, + guard: { + body: { userId, context = {} }, + }, + } = ctx; + + const subjectToken = await insertSubjectToken({ + id: `${subjectTokenPrefix}${generateStandardId()}`, + userId, + context, + expiresAt: addSeconds(new Date(), subjectTokenExpiresIn).valueOf(), + creatorId: id, + }); + + ctx.status = 201; + ctx.body = { + subjectToken: subjectToken.id, + expiresIn: subjectTokenExpiresIn, + }; + return next(); + } + ); +} diff --git a/packages/core/src/routes/swagger/index.ts b/packages/core/src/routes/swagger/index.ts index af72e4010a1..19d8f743e40 100644 --- a/packages/core/src/routes/swagger/index.ts +++ b/packages/core/src/routes/swagger/index.ts @@ -134,7 +134,11 @@ const identifiableEntityNames = Object.freeze([ ]); /** Additional tags that cannot be inferred from the path. */ -const additionalTags = Object.freeze(['Organization applications', 'Organization users']); +const additionalTags = Object.freeze([ + 'Organization applications', + 'Organization users', + 'Security', +]); /** * Attach the `/swagger.json` route which returns the generated OpenAPI document for the diff --git a/packages/core/src/tenants/Queries.ts b/packages/core/src/tenants/Queries.ts index 12f66addacc..70048b97c14 100644 --- a/packages/core/src/tenants/Queries.ts +++ b/packages/core/src/tenants/Queries.ts @@ -21,6 +21,7 @@ import { createRolesQueries } from '#src/queries/roles.js'; import { createScopeQueries } from '#src/queries/scope.js'; import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js'; import SsoConnectorQueries from '#src/queries/sso-connectors.js'; +import { createSubjectTokenQueries } from '#src/queries/subject-token.js'; import createTenantQueries from '#src/queries/tenant.js'; import UserSsoIdentityQueries from '#src/queries/user-sso-identities.js'; import { createUserQueries } from '#src/queries/user.js'; @@ -52,6 +53,7 @@ export default class Queries { organizations = new OrganizationQueries(this.pool); ssoConnectors = new SsoConnectorQueries(this.pool); userSsoIdentities = new UserSsoIdentityQueries(this.pool); + subjectTokens = createSubjectTokenQueries(this.pool); tenants = createTenantQueries(this.pool); constructor( diff --git a/packages/integration-tests/src/api/subject-token.ts b/packages/integration-tests/src/api/subject-token.ts new file mode 100644 index 00000000000..8eba5019520 --- /dev/null +++ b/packages/integration-tests/src/api/subject-token.ts @@ -0,0 +1,13 @@ +import type { JsonObject, SubjectTokenResponse } from '@logto/schemas'; + +import { authedAdminApi } from './api.js'; + +export const createSubjectToken = async (userId: string, context?: JsonObject) => + authedAdminApi + .post('security/subject-tokens', { + json: { + userId, + context, + }, + }) + .json(); diff --git a/packages/integration-tests/src/tests/api/security.test.ts b/packages/integration-tests/src/tests/api/security.test.ts new file mode 100644 index 00000000000..bc906d85e14 --- /dev/null +++ b/packages/integration-tests/src/tests/api/security.test.ts @@ -0,0 +1,19 @@ +import { createSubjectToken } from '#src/api/subject-token.js'; +import { createUserByAdmin } from '#src/helpers/index.js'; +import { devFeatureTest } from '#src/utils.js'; + +const { describe, it } = devFeatureTest; + +describe('subject-tokens', () => { + it('should create a subject token successfully', async () => { + const user = await createUserByAdmin(); + const response = await createSubjectToken(user.id, { test: 'test' }); + + expect(response.subjectToken).toContain('sub_'); + expect(response.expiresIn).toBeGreaterThan(0); + }); + + it('should fail to create a subject token with a non-existent user', async () => { + await expect(createSubjectToken('non-existent-user')).rejects.toThrow(); + }); +}); diff --git a/packages/schemas/alterations/next-1718865814-add-subject-tokens.ts b/packages/schemas/alterations/next-1718865814-add-subject-tokens.ts new file mode 100644 index 00000000000..051fec3f65f --- /dev/null +++ b/packages/schemas/alterations/next-1718865814-add-subject-tokens.ts @@ -0,0 +1,36 @@ +import { sql } from '@silverhand/slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +import { applyTableRls, dropTableRls } from './utils/1704934999-tables.js'; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + create table subject_tokens ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + id varchar(25) not null, + context jsonb /* @use JsonObject */ not null default '{}'::jsonb, + expires_at timestamptz not null, + consumed_at timestamptz, + user_id varchar(21) not null + references users (id) on update cascade on delete cascade, + created_at timestamptz not null default(now()), + creator_id varchar(32) not null, /* It is intented to not reference to user or application table */ + primary key (id) + ); + + create index subject_token__id on subject_tokens (tenant_id, id); + `); + await applyTableRls(pool, 'subject_tokens'); + }, + down: async (pool) => { + await dropTableRls(pool, 'subject_tokens'); + await pool.query(sql` + drop table subject_tokens + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index 5374430c77a..d6bfbac974a 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -28,3 +28,4 @@ export * from './mapi-proxy.js'; export * from './consent.js'; export * from './onboarding.js'; export * from './sign-in-experience.js'; +export * from './subject-token.js'; diff --git a/packages/schemas/src/types/subject-token.ts b/packages/schemas/src/types/subject-token.ts new file mode 100644 index 00000000000..001b34fece4 --- /dev/null +++ b/packages/schemas/src/types/subject-token.ts @@ -0,0 +1,8 @@ +import { number, object, string, type z } from 'zod'; + +export const subjectTokenResponseGuard = object({ + subjectToken: string(), + expiresIn: number(), +}); + +export type SubjectTokenResponse = z.infer; diff --git a/packages/schemas/tables/subject_tokens.sql b/packages/schemas/tables/subject_tokens.sql new file mode 100644 index 00000000000..d8879f7dae0 --- /dev/null +++ b/packages/schemas/tables/subject_tokens.sql @@ -0,0 +1,16 @@ +create table subject_tokens ( + tenant_id varchar(21) not null + references tenants (id) on update cascade on delete cascade, + id varchar(25) not null, + context jsonb /* @use JsonObject */ not null default '{}'::jsonb, + expires_at timestamptz not null, + consumed_at timestamptz, + user_id varchar(21) not null + references users (id) on update cascade on delete cascade, + created_at timestamptz not null default(now()), + /* It is intented to not reference to user or application table, it can be userId or applicationId, for audit only */ + creator_id varchar(32) not null, + primary key (id) +); + +create index subject_token__id on subject_tokens (tenant_id, id);