0
Fork 0
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:
wangsijie 2024-09-10 18:30:26 +08:00 committed by GitHub
parent 2626616775
commit b639249159
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 274 additions and 46 deletions

View file

@ -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 () => {

View file

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

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

View 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;
}

View 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[];
};

View file

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

View file

@ -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,

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

View file

@ -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>>;

View file

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