mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -05:00
feat(core): add koa oidc auth for profile API (#6559)
This commit is contained in:
parent
2626616775
commit
b639249159
10 changed files with 274 additions and 46 deletions
|
@ -12,9 +12,9 @@ import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
|||
import type { WithAuthContext } from './index.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
const { mockEsm } = createMockUtils(jest);
|
||||
const { mockEsmWithActual, mockEsm } = createMockUtils(jest);
|
||||
|
||||
mockEsm('./utils.js', () => ({
|
||||
await mockEsmWithActual('./utils.js', () => ({
|
||||
getAdminTenantTokenValidationSet: jest.fn().mockResolvedValue({ keys: [], issuer: [] }),
|
||||
}));
|
||||
|
||||
|
@ -39,6 +39,7 @@ describe('koaAuth middleware', () => {
|
|||
auth: {
|
||||
type: 'user',
|
||||
id: '',
|
||||
scopes: new Set(),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -62,6 +63,7 @@ describe('koaAuth middleware', () => {
|
|||
ctx.auth = {
|
||||
type: 'user',
|
||||
id: '',
|
||||
scopes: new Set(),
|
||||
};
|
||||
ctx.request = baseCtx.request;
|
||||
jest.resetModules();
|
||||
|
@ -74,7 +76,7 @@ describe('koaAuth middleware', () => {
|
|||
});
|
||||
|
||||
await koaAuth(mockEnvSet, audience)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'foo', scopes: new Set(['all']) });
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
@ -89,7 +91,7 @@ describe('koaAuth middleware', () => {
|
|||
};
|
||||
|
||||
await koaAuth(mockEnvSet, audience)(mockCtx, next);
|
||||
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo', scopes: new Set(['all']) });
|
||||
});
|
||||
|
||||
it('should read DEVELOPMENT_USER_ID from env variable first if is in production and integration test', async () => {
|
||||
|
@ -101,7 +103,7 @@ describe('koaAuth middleware', () => {
|
|||
});
|
||||
|
||||
await koaAuth(mockEnvSet, audience)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'foo', scopes: new Set(['all']) });
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
@ -122,7 +124,7 @@ describe('koaAuth middleware', () => {
|
|||
};
|
||||
|
||||
await koaAuth(mockEnvSet, audience)(mockCtx, next);
|
||||
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo' });
|
||||
expect(mockCtx.auth).toEqual({ type: 'user', id: 'foo', scopes: new Set(['all']) });
|
||||
|
||||
stub.restore();
|
||||
});
|
||||
|
@ -135,7 +137,7 @@ describe('koaAuth middleware', () => {
|
|||
},
|
||||
};
|
||||
await koaAuth(mockEnvSet, audience)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' });
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser', scopes: new Set(['all']) });
|
||||
});
|
||||
|
||||
it('expect to throw if authorization header is missing', async () => {
|
||||
|
@ -183,7 +185,7 @@ describe('koaAuth middleware', () => {
|
|||
};
|
||||
|
||||
await koaAuth(mockEnvSet, audience)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'app', id: 'bar' });
|
||||
expect(ctx.auth).toEqual({ type: 'app', id: 'bar', scopes: new Set(['all']) });
|
||||
});
|
||||
|
||||
it('expect to throw if jwt scope is missing', async () => {
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
|
||||
import { adminTenantId, defaultManagementApi, PredefinedScope } from '@logto/schemas';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import type { JWK } from 'jose';
|
||||
|
@ -14,41 +12,10 @@ import RequestError from '#src/errors/RequestError/index.js';
|
|||
import assertThat from '#src/utils/assert-that.js';
|
||||
import { devConsole } from '#src/utils/console.js';
|
||||
|
||||
import { getAdminTenantTokenValidationSet } from './utils.js';
|
||||
import { type WithAuthContext, type TokenInfo } from './types.js';
|
||||
import { extractBearerTokenFromHeaders, getAdminTenantTokenValidationSet } from './utils.js';
|
||||
|
||||
export type Auth = {
|
||||
type: 'user' | 'app';
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamContext> =
|
||||
ContextT & {
|
||||
auth: Auth;
|
||||
};
|
||||
|
||||
const bearerTokenIdentifier = 'Bearer';
|
||||
|
||||
export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
||||
assertThat(
|
||||
authorization,
|
||||
new RequestError({ code: 'auth.authorization_header_missing', status: 401 })
|
||||
);
|
||||
assertThat(
|
||||
authorization.startsWith(bearerTokenIdentifier),
|
||||
new RequestError(
|
||||
{ code: 'auth.authorization_token_type_not_supported', status: 401 },
|
||||
{ supportedTypes: [bearerTokenIdentifier] }
|
||||
)
|
||||
);
|
||||
|
||||
return authorization.slice(bearerTokenIdentifier.length + 1);
|
||||
};
|
||||
|
||||
type TokenInfo = {
|
||||
sub: string;
|
||||
clientId: unknown;
|
||||
scopes: string[];
|
||||
};
|
||||
export * from './types.js';
|
||||
|
||||
export const verifyBearerTokenFromRequest = async (
|
||||
envSet: EnvSet,
|
||||
|
@ -146,6 +113,7 @@ export default function koaAuth<StateT, ContextT extends IRouterParamContext, Re
|
|||
ctx.auth = {
|
||||
type: sub === clientId ? 'app' : 'user',
|
||||
id: sub,
|
||||
scopes: new Set(scopes),
|
||||
};
|
||||
|
||||
return next();
|
||||
|
|
133
packages/core/src/middleware/koa-auth/koa-oidc-auth.test.ts
Normal file
133
packages/core/src/middleware/koa-auth/koa-oidc-auth.test.ts
Normal file
|
@ -0,0 +1,133 @@
|
|||
import { pickDefault } from '@logto/shared/esm';
|
||||
import type { Context } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import Provider from 'oidc-provider';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
import type { WithAuthContext } from './index.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
const provider = new Provider('https://logto.test');
|
||||
const mockAccessToken = {
|
||||
accountId: 'fooUser',
|
||||
clientId: 'fooClient',
|
||||
scopes: new Set(['openid']),
|
||||
};
|
||||
|
||||
const koaOidcAuth = await pickDefault(import('./koa-oidc-auth.js'));
|
||||
|
||||
afterEach(() => {
|
||||
Sinon.restore();
|
||||
});
|
||||
|
||||
describe('koaOidcAuth middleware', () => {
|
||||
const baseCtx = createContextWithRouteParameters();
|
||||
|
||||
const ctx: WithAuthContext<Context & IRouterParamContext> = {
|
||||
...baseCtx,
|
||||
auth: {
|
||||
type: 'user',
|
||||
id: '',
|
||||
scopes: new Set(),
|
||||
},
|
||||
};
|
||||
|
||||
const authHeaderMissingError = new RequestError({
|
||||
code: 'auth.authorization_header_missing',
|
||||
status: 401,
|
||||
});
|
||||
const tokenNotSupportedError = new RequestError(
|
||||
{
|
||||
code: 'auth.authorization_token_type_not_supported',
|
||||
status: 401,
|
||||
},
|
||||
{ supportedTypes: ['Bearer'] }
|
||||
);
|
||||
const unauthorizedError = new RequestError({ code: 'auth.unauthorized', status: 401 });
|
||||
const forbiddenError = new RequestError({ code: 'auth.forbidden', status: 403 });
|
||||
|
||||
const next = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.auth = {
|
||||
type: 'user',
|
||||
id: '',
|
||||
scopes: new Set(),
|
||||
};
|
||||
ctx.request = baseCtx.request;
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
it('should set user auth with given sub returned from accessToken', async () => {
|
||||
ctx.request = {
|
||||
...ctx.request,
|
||||
headers: {
|
||||
authorization: 'Bearer access_token',
|
||||
},
|
||||
};
|
||||
Sinon.stub(provider.AccessToken, 'find').resolves(mockAccessToken);
|
||||
await koaOidcAuth(provider)(ctx, next);
|
||||
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser', scopes: new Set(['openid']) });
|
||||
});
|
||||
|
||||
it('expect to throw if authorization header is missing', async () => {
|
||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(authHeaderMissingError);
|
||||
});
|
||||
|
||||
it('expect to throw if authorization header token type not recognized ', async () => {
|
||||
ctx.request = {
|
||||
...ctx.request,
|
||||
headers: {
|
||||
authorization: 'dummy access_token',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(tokenNotSupportedError);
|
||||
});
|
||||
|
||||
it('expect to throw if access token is not found', async () => {
|
||||
ctx.request = {
|
||||
...ctx.request,
|
||||
headers: {
|
||||
authorization: 'Bearer access_token',
|
||||
},
|
||||
};
|
||||
Sinon.stub(provider.AccessToken, 'find').resolves();
|
||||
|
||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError);
|
||||
});
|
||||
|
||||
it('expect to throw if sub is missing', async () => {
|
||||
ctx.request = {
|
||||
...ctx.request,
|
||||
headers: {
|
||||
authorization: 'Bearer access_token',
|
||||
},
|
||||
};
|
||||
Sinon.stub(provider.AccessToken, 'find').resolves({
|
||||
...mockAccessToken,
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError);
|
||||
});
|
||||
|
||||
it('expect to throw if access token does not have openid scope', async () => {
|
||||
ctx.request = {
|
||||
...ctx.request,
|
||||
headers: {
|
||||
authorization: 'Bearer access_token',
|
||||
},
|
||||
};
|
||||
Sinon.stub(provider.AccessToken, 'find').resolves({
|
||||
...mockAccessToken,
|
||||
scopes: new Set(['foo']),
|
||||
});
|
||||
|
||||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(forbiddenError);
|
||||
});
|
||||
});
|
41
packages/core/src/middleware/koa-auth/koa-oidc-auth.ts
Normal file
41
packages/core/src/middleware/koa-auth/koa-oidc-auth.ts
Normal file
|
@ -0,0 +1,41 @@
|
|||
import type { MiddlewareType } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
import { type WithAuthContext } from './types.js';
|
||||
import { extractBearerTokenFromHeaders } from './utils.js';
|
||||
|
||||
/**
|
||||
* Auth middleware for OIDC opaque token
|
||||
*/
|
||||
export default function koaOidcAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||
provider: Provider
|
||||
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
|
||||
const authMiddleware: MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> = async (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const { request } = ctx;
|
||||
const accessTokenValue = extractBearerTokenFromHeaders(request.headers);
|
||||
const accessToken = await provider.AccessToken.find(accessTokenValue);
|
||||
|
||||
assertThat(accessToken, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
|
||||
const { accountId, scopes } = accessToken;
|
||||
assertThat(accountId, new RequestError({ code: 'auth.unauthorized', status: 401 }));
|
||||
assertThat(scopes.has('openid'), new RequestError({ code: 'auth.forbidden', status: 403 }));
|
||||
|
||||
ctx.auth = {
|
||||
type: 'user',
|
||||
id: accountId,
|
||||
scopes,
|
||||
};
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
return authMiddleware;
|
||||
}
|
18
packages/core/src/middleware/koa-auth/types.ts
Normal file
18
packages/core/src/middleware/koa-auth/types.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { type IRouterParamContext } from 'koa-router';
|
||||
|
||||
type Auth = {
|
||||
type: 'user' | 'app';
|
||||
id: string;
|
||||
scopes: Set<string>;
|
||||
};
|
||||
|
||||
export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamContext> =
|
||||
ContextT & {
|
||||
auth: Auth;
|
||||
};
|
||||
|
||||
export type TokenInfo = {
|
||||
sub: string;
|
||||
clientId: unknown;
|
||||
scopes: string[];
|
||||
};
|
|
@ -1,3 +1,5 @@
|
|||
import { type IncomingHttpHeaders } from 'node:http';
|
||||
|
||||
import { adminTenantId } from '@logto/schemas';
|
||||
import { TtlCache } from '@logto/shared';
|
||||
import { appendPath, isKeyInObject } from '@silverhand/essentials';
|
||||
|
@ -6,6 +8,9 @@ import ky from 'ky';
|
|||
|
||||
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
|
||||
|
||||
import RequestError from '../../errors/RequestError/index.js';
|
||||
import assertThat from '../../utils/assert-that.js';
|
||||
|
||||
const jwksCache = new TtlCache<string, JWK[]>(60 * 60 * 1000); // 1 hour
|
||||
|
||||
/**
|
||||
|
@ -58,3 +63,21 @@ export const getAdminTenantTokenValidationSet = async (): Promise<{
|
|||
issuer: [issuer.href],
|
||||
};
|
||||
};
|
||||
|
||||
const bearerTokenIdentifier = 'Bearer';
|
||||
|
||||
export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
||||
assertThat(
|
||||
authorization,
|
||||
new RequestError({ code: 'auth.authorization_header_missing', status: 401 })
|
||||
);
|
||||
assertThat(
|
||||
authorization.startsWith(bearerTokenIdentifier),
|
||||
new RequestError(
|
||||
{ code: 'auth.authorization_token_type_not_supported', status: 401 },
|
||||
{ supportedTypes: [bearerTokenIdentifier] }
|
||||
)
|
||||
);
|
||||
|
||||
return authorization.slice(bearerTokenIdentifier.length + 1);
|
||||
};
|
||||
|
|
|
@ -32,6 +32,7 @@ import interactionRoutes from './interaction/index.js';
|
|||
import logRoutes from './log.js';
|
||||
import logtoConfigRoutes from './logto-config/index.js';
|
||||
import organizationRoutes from './organization/index.js';
|
||||
import profileRoutes from './profile/index.js';
|
||||
import resourceRoutes from './resource.js';
|
||||
import resourceScopeRoutes from './resource.scope.js';
|
||||
import roleRoutes from './role.js';
|
||||
|
@ -42,7 +43,7 @@ import statusRoutes from './status.js';
|
|||
import subjectTokenRoutes from './subject-token.js';
|
||||
import swaggerRoutes from './swagger/index.js';
|
||||
import systemRoutes from './system.js';
|
||||
import type { AnonymousRouter, ManagementApiRouter } from './types.js';
|
||||
import type { AnonymousRouter, ManagementApiRouter, ProfileRouter } from './types.js';
|
||||
import userAssetsRoutes from './user-assets.js';
|
||||
import verificationCodeRoutes from './verification-code.js';
|
||||
import wellKnownRoutes from './well-known/index.js';
|
||||
|
@ -105,6 +106,11 @@ const createRouters = (tenant: TenantContext) => {
|
|||
statusRoutes(anonymousRouter, tenant);
|
||||
authnRoutes(anonymousRouter, tenant);
|
||||
|
||||
if (EnvSet.values.isDevFeaturesEnabled) {
|
||||
const profileRouter: ProfileRouter = new Router();
|
||||
profileRoutes(profileRouter, tenant);
|
||||
}
|
||||
|
||||
// The swagger.json should contain all API routers.
|
||||
swaggerRoutes(anonymousRouter, [
|
||||
managementRouter,
|
||||
|
|
35
packages/core/src/routes/profile/index.ts
Normal file
35
packages/core/src/routes/profile/index.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
|
||||
import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js';
|
||||
import type { ProfileRouter, RouterInitArgs } from '../types.js';
|
||||
|
||||
/**
|
||||
* Authn stands for authentication.
|
||||
* This router will have a route `/authn` to authenticate tokens with a general manner.
|
||||
*/
|
||||
export default function profileRoutes<T extends ProfileRouter>(
|
||||
...[router, { provider }]: RouterInitArgs<T>
|
||||
) {
|
||||
router.use(koaOidcAuth(provider));
|
||||
|
||||
// TODO: test route only, will implement a better one later
|
||||
router.get(
|
||||
'/profile',
|
||||
koaGuard({
|
||||
response: z.object({
|
||||
sub: z.string(),
|
||||
}),
|
||||
status: [200],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
ctx.body = {
|
||||
sub: ctx.auth.id,
|
||||
};
|
||||
ctx.status = 200;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -17,5 +17,7 @@ export type ManagementApiRouterContext = WithAuthContext &
|
|||
|
||||
export type ManagementApiRouter = Router<unknown, ManagementApiRouterContext>;
|
||||
|
||||
export type ProfileRouter = Router<unknown, WithAuthContext & WithLogContext & WithI18nContext>;
|
||||
|
||||
type RouterInit<T> = (router: T, tenant: TenantContext) => void;
|
||||
export type RouterInitArgs<T> = Parameters<RouterInit<T>>;
|
||||
|
|
|
@ -146,7 +146,7 @@ export function createRequester<StateT, ContextT extends IRouterParamContext, Re
|
|||
const authRouter: ManagementApiRouter = new Router();
|
||||
|
||||
authRouter.use(async (ctx, next) => {
|
||||
ctx.auth = { type: 'user', id: 'foo' };
|
||||
ctx.auth = { type: 'user', id: 'foo', scopes: new Set() };
|
||||
|
||||
return next();
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue