Skip to content

Commit

Permalink
feat(core): issue subject tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Jun 22, 2024
1 parent 6f06c41 commit 39ae8ba
Show file tree
Hide file tree
Showing 13 changed files with 217 additions and 1 deletion.
4 changes: 4 additions & 0 deletions packages/core/src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -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_';
14 changes: 14 additions & 0 deletions packages/core/src/queries/subject-token.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
2 changes: 2 additions & 0 deletions packages/core/src/routes/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/routes/security/index.openapi.json
Original file line number Diff line number Diff line change
@@ -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 changes: 56 additions & 0 deletions packages/core/src/routes/security/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}

Check warning on line 20 in packages/core/src/routes/security/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/security/index.ts#L19-L20

Added lines #L19 - L20 were not covered by tests

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

Check warning on line 54 in packages/core/src/routes/security/index.ts

View check run for this annotation

Codecov / codecov/patch

packages/core/src/routes/security/index.ts#L33-L54

Added lines #L33 - L54 were not covered by tests
);
}
6 changes: 5 additions & 1 deletion packages/core/src/routes/swagger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/tenants/Queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 13 additions & 0 deletions packages/integration-tests/src/api/subject-token.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions packages/integration-tests/src/tests/api/security.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
36 changes: 36 additions & 0 deletions packages/schemas/alterations/next-1718865814-add-subject-tokens.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions packages/schemas/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
8 changes: 8 additions & 0 deletions packages/schemas/src/types/subject-token.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 16 additions & 0 deletions packages/schemas/tables/subject_tokens.sql
Original file line number Diff line number Diff line change
@@ -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);

0 comments on commit 39ae8ba

Please sign in to comment.