mirror of
https://github.com/logto-io/logto.git
synced 2025-01-13 21:30:30 -05:00
feat(core): issue subject tokens (#6045)
This commit is contained in:
parent
6f06c418b2
commit
b1a12fb375
13 changed files with 217 additions and 1 deletions
|
@ -1 +1,5 @@
|
||||||
export const protectedAppSignInCallbackUrl = 'sign-in-callback';
|
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_';
|
||||||
|
|
14
packages/core/src/queries/subject-token.ts
Normal file
14
packages/core/src/queries/subject-token.ts
Normal file
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -32,6 +32,7 @@ import resourceRoutes from './resource.js';
|
||||||
import resourceScopeRoutes from './resource.scope.js';
|
import resourceScopeRoutes from './resource.scope.js';
|
||||||
import roleRoutes from './role.js';
|
import roleRoutes from './role.js';
|
||||||
import roleScopeRoutes from './role.scope.js';
|
import roleScopeRoutes from './role.scope.js';
|
||||||
|
import securityRoutes from './security/index.js';
|
||||||
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
||||||
import ssoConnectors from './sso-connector/index.js';
|
import ssoConnectors from './sso-connector/index.js';
|
||||||
import statusRoutes from './status.js';
|
import statusRoutes from './status.js';
|
||||||
|
@ -79,6 +80,7 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
organizationRoutes(managementRouter, tenant);
|
organizationRoutes(managementRouter, tenant);
|
||||||
ssoConnectors(managementRouter, tenant);
|
ssoConnectors(managementRouter, tenant);
|
||||||
systemRoutes(managementRouter, tenant);
|
systemRoutes(managementRouter, tenant);
|
||||||
|
securityRoutes(managementRouter, tenant);
|
||||||
|
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
const anonymousRouter: AnonymousRouter = new Router();
|
||||||
wellKnownRoutes(anonymousRouter, tenant);
|
wellKnownRoutes(anonymousRouter, tenant);
|
||||||
|
|
41
packages/core/src/routes/security/index.openapi.json
Normal file
41
packages/core/src/routes/security/index.openapi.json
Normal file
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
56
packages/core/src/routes/security/index.ts
Normal file
56
packages/core/src/routes/security/index.ts
Normal file
|
@ -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<T extends ManagementApiRouter>(...args: RouterInitArgs<T>) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -134,7 +134,11 @@ const identifiableEntityNames = Object.freeze([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Additional tags that cannot be inferred from the path. */
|
/** 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
|
* Attach the `/swagger.json` route which returns the generated OpenAPI document for the
|
||||||
|
|
|
@ -21,6 +21,7 @@ import { createRolesQueries } from '#src/queries/roles.js';
|
||||||
import { createScopeQueries } from '#src/queries/scope.js';
|
import { createScopeQueries } from '#src/queries/scope.js';
|
||||||
import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js';
|
import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js';
|
||||||
import SsoConnectorQueries from '#src/queries/sso-connectors.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 createTenantQueries from '#src/queries/tenant.js';
|
||||||
import UserSsoIdentityQueries from '#src/queries/user-sso-identities.js';
|
import UserSsoIdentityQueries from '#src/queries/user-sso-identities.js';
|
||||||
import { createUserQueries } from '#src/queries/user.js';
|
import { createUserQueries } from '#src/queries/user.js';
|
||||||
|
@ -52,6 +53,7 @@ export default class Queries {
|
||||||
organizations = new OrganizationQueries(this.pool);
|
organizations = new OrganizationQueries(this.pool);
|
||||||
ssoConnectors = new SsoConnectorQueries(this.pool);
|
ssoConnectors = new SsoConnectorQueries(this.pool);
|
||||||
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
|
userSsoIdentities = new UserSsoIdentityQueries(this.pool);
|
||||||
|
subjectTokens = createSubjectTokenQueries(this.pool);
|
||||||
tenants = createTenantQueries(this.pool);
|
tenants = createTenantQueries(this.pool);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
|
13
packages/integration-tests/src/api/subject-token.ts
Normal file
13
packages/integration-tests/src/api/subject-token.ts
Normal file
|
@ -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<SubjectTokenResponse>();
|
19
packages/integration-tests/src/tests/api/security.test.ts
Normal file
19
packages/integration-tests/src/tests/api/security.test.ts
Normal file
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
|
@ -28,3 +28,4 @@ export * from './mapi-proxy.js';
|
||||||
export * from './consent.js';
|
export * from './consent.js';
|
||||||
export * from './onboarding.js';
|
export * from './onboarding.js';
|
||||||
export * from './sign-in-experience.js';
|
export * from './sign-in-experience.js';
|
||||||
|
export * from './subject-token.js';
|
||||||
|
|
8
packages/schemas/src/types/subject-token.ts
Normal file
8
packages/schemas/src/types/subject-token.ts
Normal file
|
@ -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<typeof subjectTokenResponseGuard>;
|
16
packages/schemas/tables/subject_tokens.sql
Normal file
16
packages/schemas/tables/subject_tokens.sql
Normal file
|
@ -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);
|
Loading…
Add table
Reference in a new issue