Skip to content

Commit

Permalink
refactor(schemas,core,cli): alterate signing key type to json object
Browse files Browse the repository at this point in the history
  • Loading branch information
charIeszhao committed Sep 25, 2023
1 parent 827123f commit a0dae10
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 22 deletions.
26 changes: 17 additions & 9 deletions packages/cli/src/commands/database/seed/oidc-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises';

import type { LogtoOidcConfigType } from '@logto/schemas';
import { LogtoOidcConfigKey, logtoConfigGuards } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { getEnvAsStringArray } from '@silverhand/essentials';
import chalk from 'chalk';
import type { DatabaseTransactionConnection } from 'slonik';
Expand Down Expand Up @@ -85,16 +86,16 @@ export const oidcConfigReaders: {
[LogtoOidcConfigKey.PrivateKeys]: async () => {
// Direct keys in env
const privateKeys = getEnvAsStringArray('OIDC_PRIVATE_KEYS');
const id = generateStandardId();
const createdAt = Math.floor(Date.now() / 1000);

if (privateKeys.length > 0) {
return {
value: privateKeys.map((key) => {
if (isBase64FormatPrivateKey(key)) {
return Buffer.from(key, 'base64').toString('utf8');
}

return key;
}),
value: privateKeys.map((key) => ({
id,
value: isBase64FormatPrivateKey(key) ? Buffer.from(key, 'base64').toString('utf8') : key,
createdAt,
})),
fromEnv: true,
};
}
Expand All @@ -103,8 +104,11 @@ export const oidcConfigReaders: {
const privateKeyPaths = getEnvAsStringArray('OIDC_PRIVATE_KEY_PATHS');

if (privateKeyPaths.length > 0) {
const privateKeys = await Promise.all(
privateKeyPaths.map(async (path) => readFile(path, 'utf8'))
);
return {
value: await Promise.all(privateKeyPaths.map(async (path) => readFile(path, 'utf8'))),
value: privateKeys.map((key) => ({ id, value: key, createdAt })),
fromEnv: true,
};
}
Expand All @@ -116,7 +120,11 @@ export const oidcConfigReaders: {
},
[LogtoOidcConfigKey.CookieKeys]: async () => {
const envKey = 'OIDC_COOKIE_KEYS';
const keys = getEnvAsStringArray(envKey);
const keys = getEnvAsStringArray(envKey).map((key) => ({
id: generateStandardId(),
value: key,
createdAt: Math.floor(Date.now() / 1000),
}));

return { value: keys.length > 0 ? keys : [generateOidcCookieKey()], fromEnv: keys.length > 0 };
},
Expand Down
20 changes: 15 additions & 5 deletions packages/cli/src/commands/database/utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { generateKeyPair } from 'node:crypto';
import { promisify } from 'node:util';

import { generateStandardSecret } from '@logto/shared';
import { type PrivateKey } from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared';

export enum PrivateKeyType {
RSA = 'rsa',
EC = 'ec',
}

export const generateOidcPrivateKey = async (type: PrivateKeyType = PrivateKeyType.EC) => {
export const generateOidcPrivateKey = async (
type: PrivateKeyType = PrivateKeyType.EC
): Promise<PrivateKey> => {
const id = generateStandardId();
const createdAt = Math.floor(Date.now() / 1000);

if (type === PrivateKeyType.RSA) {
const { privateKey } = await promisify(generateKeyPair)('rsa', {
modulusLength: 4096,
Expand All @@ -22,7 +28,7 @@ export const generateOidcPrivateKey = async (type: PrivateKeyType = PrivateKeyTy
},
});

return privateKey;
return { id, value: privateKey, createdAt };
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
Expand All @@ -40,10 +46,14 @@ export const generateOidcPrivateKey = async (type: PrivateKeyType = PrivateKeyTy
},
});

return privateKey;
return { id, value: privateKey, createdAt };
}

throw new Error(`Unsupported private key ${String(type)}`);
};

export const generateOidcCookieKey = () => generateStandardSecret();
export const generateOidcCookieKey = () => ({
id: generateStandardId(),
value: generateStandardSecret(),
createdAt: Math.floor(Date.now() / 1000),
});
6 changes: 3 additions & 3 deletions packages/core/src/env-set/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { createLocalJWKSet } from 'jose';
import { exportJWK } from '#src/utils/jwks.js';

const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => {
const cookieKeys = configs[LogtoOidcConfigKey.CookieKeys];
const privateKeys = configs[LogtoOidcConfigKey.PrivateKeys].map((key) =>
crypto.createPrivateKey(key)
const cookieKeys = configs[LogtoOidcConfigKey.CookieKeys].map(({ value }) => value);
const privateKeys = configs[LogtoOidcConfigKey.PrivateKeys].map(({ value }) =>
crypto.createPrivateKey(value)
);
const publicKeys = privateKeys.map((key) => crypto.createPublicKey(key));
const privateJwks = await Promise.all(privateKeys.map(async (key) => exportJWK(key)));
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/middleware/koa-auth/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
`);
const privateKeys = logtoOidcConfigGuard['oidc.privateKeys']
.parse(value)
.map((key) => crypto.createPrivateKey(key));
.map(({ value }) => crypto.createPrivateKey(value));
const publicKeys = privateKeys.map((key) => crypto.createPublicKey(key));

return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { generateStandardId } from '@logto/shared';
import type { DatabaseTransactionConnection } from 'slonik';
import { sql } from 'slonik';

import type { AlterationScript } from '../lib/types/alteration.js';

const targetConfigKeys = ['oidc.cookieKeys', 'oidc.privateKeys'];

type OldPrivateKeyData = {
tenantId: string;
value: string[];
};

type PrivateKey = {
id: string;
value: string;
createdAt: number;
};

type NewPrivateKeyData = {
tenantId: string;
value: PrivateKey[];
};

/**
* Alternate string array private signing keys to JSON array
* "oidc.cookieKeys": string[] -> PrivateKey[]
* "oidc.privateKeys": string[] -> PrivateKey[]
* @param configKey oidc.cookieKeys | oidc.privateKeys
* @param logtoConfig existing private key data for a specific tenant
* @param pool postgres database connection pool
*/
const alterPrivateKeysInLogtoConfig = async (
configKey: string,
logtoConfig: OldPrivateKeyData,
pool: DatabaseTransactionConnection
) => {
const { tenantId, value: oldPrivateKey } = logtoConfig;

// Use tenant creation time as `createdAt` timestamp for new private keys
const tenantData = await pool.maybeOne<{ createdAt: number }>(
sql`select * from tenants where id = ${tenantId}`
);
const newPrivateKeyData: PrivateKey[] = oldPrivateKey.map((key) => ({
id: generateStandardId(),
value: key,
createdAt: Math.floor((tenantData?.createdAt ?? Date.now()) / 1000),
}));

await pool.query(
sql`update logto_configs set value = ${JSON.stringify(
newPrivateKeyData
)} where tenant_id = ${tenantId} and key = ${configKey}`
);
};

/**
* Rollback JSON array private signing keys to string array
* "oidc.cookieKeys": PrivateKey[] -> string[]
* "oidc.privateKeys": PrivateKey[] -> string[]
* @param configKey oidc.cookieKeys | oidc.privateKeys
* @param logtoConfig new private key data for a specific tenant
* @param pool postgres database connection pool
*/
const rollbackPrivateKeysInLogtoConfig = async (
configKey: string,
logtoConfig: NewPrivateKeyData,
pool: DatabaseTransactionConnection
) => {
const { tenantId, value: newPrivateKeyData } = logtoConfig;

const oldPrivateKeys = newPrivateKeyData.map(({ value }) => value);

await pool.query(
sql`update logto_configs set value = ${JSON.stringify(
oldPrivateKeys
)} where tenant_id = ${tenantId} and key = ${configKey}`
);
};

const alteration: AlterationScript = {
up: async (pool) => {
await Promise.all(
targetConfigKeys.map(async (configKey) => {
const rows = await pool.many<OldPrivateKeyData>(
sql`select * from logto_configs where key = ${configKey}`
);
await Promise.all(
rows.map(async (row) => alterPrivateKeysInLogtoConfig(configKey, row, pool))
);
})
);
},
down: async (pool) => {
await Promise.all(
targetConfigKeys.map(async (configKey) => {
const rows = await pool.many<NewPrivateKeyData>(
sql`select * from logto_configs where key = ${configKey}`
);
await Promise.all(
rows.map(async (row) => rollbackPrivateKeysInLogtoConfig(configKey, row, pool))
);
})
);
},
};

export default alteration;
16 changes: 12 additions & 4 deletions packages/schemas/src/types/logto-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,24 @@ export enum LogtoOidcConfigKey {
CookieKeys = 'oidc.cookieKeys',
}

const oidcPrivateKeyGuard = z.object({
id: z.string(),
value: z.string(),
createdAt: z.number(),
});

export type PrivateKey = z.infer<typeof oidcPrivateKeyGuard>;

export type LogtoOidcConfigType = {
[LogtoOidcConfigKey.PrivateKeys]: string[];
[LogtoOidcConfigKey.CookieKeys]: string[];
[LogtoOidcConfigKey.PrivateKeys]: PrivateKey[];
[LogtoOidcConfigKey.CookieKeys]: PrivateKey[];
};

export const logtoOidcConfigGuard: Readonly<{
[key in LogtoOidcConfigKey]: ZodType<LogtoOidcConfigType[key]>;
}> = Object.freeze({
[LogtoOidcConfigKey.PrivateKeys]: z.string().array(),
[LogtoOidcConfigKey.CookieKeys]: z.string().array(),
[LogtoOidcConfigKey.PrivateKeys]: oidcPrivateKeyGuard.array(),
[LogtoOidcConfigKey.CookieKeys]: oidcPrivateKeyGuard.array(),
});

/* --- Logto tenant configs --- */
Expand Down

0 comments on commit a0dae10

Please sign in to comment.