diff --git a/packages/core/src/middleware/koa-auth.test.ts b/packages/core/src/middleware/koa-auth.test.ts index fd5b01f33..b0549fb6c 100644 --- a/packages/core/src/middleware/koa-auth.test.ts +++ b/packages/core/src/middleware/koa-auth.test.ts @@ -185,6 +185,8 @@ describe('koaAuth middleware', () => { }, }; - await expect(koaAuth()(ctx, next)).rejects.toMatchError(unauthorizedError); + await expect(koaAuth()(ctx, next)).rejects.toMatchError( + new RequestError({ code: 'auth.unauthorized', status: 401 }, new Error('unknown error')) + ); }); }); diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts index b79aa130e..cfaeb10db 100644 --- a/packages/core/src/middleware/koa-auth.ts +++ b/packages/core/src/middleware/koa-auth.ts @@ -34,12 +34,15 @@ const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) = return authorization.slice(bearerTokenIdentifier.length + 1); }; -type UserInfo = { +type TokenInfo = { sub: string; roleNames?: string[]; }; -const getUserInfoFromRequest = async (request: Request): Promise => { +export const verifyBearerTokenFromRequest = async ( + request: Request, + resourceIndicator = managementResource.indicator +): Promise => { const { isProduction, isIntegrationTest, developmentUserId, oidc } = envSet.values; const userId = request.headers['development-user-id']?.toString() ?? developmentUserId; @@ -47,41 +50,42 @@ const getUserInfoFromRequest = async (request: Request): Promise => { return { sub: userId, roleNames: [UserRole.Admin] }; } - const { localJWKSet, issuer } = oidc; - const { - payload: { sub, role_names: roleNames }, - } = await jwtVerify(extractBearerTokenFromHeaders(request.headers), localJWKSet, { - issuer, - audience: managementResource.indicator, - }); + try { + const { localJWKSet, issuer } = oidc; + const { + payload: { sub, role_names: roleNames }, + } = await jwtVerify(extractBearerTokenFromHeaders(request.headers), localJWKSet, { + issuer, + audience: resourceIndicator, + }); - assertThat(sub, new RequestError({ code: 'auth.jwt_sub_missing', status: 401 })); + assertThat(sub, new RequestError({ code: 'auth.jwt_sub_missing', status: 401 })); - return { sub, roleNames: conditional(Array.isArray(roleNames) && roleNames) }; + return { sub, roleNames: conditional(Array.isArray(roleNames) && roleNames) }; + } catch (error: unknown) { + if (error instanceof RequestError) { + throw error; + } + + throw new RequestError({ code: 'auth.unauthorized', status: 401 }, error); + } }; export default function koaAuth( forRole?: UserRole ): MiddlewareType, ResponseBodyT> { return async (ctx, next) => { - try { - const { sub, roleNames } = await getUserInfoFromRequest(ctx.request); + const { sub, roleNames } = await verifyBearerTokenFromRequest(ctx.request); - if (forRole) { - assertThat( - roleNames?.includes(forRole), - new RequestError({ code: 'auth.forbidden', status: 403 }) - ); - } - - ctx.auth = sub; - } catch (error: unknown) { - if (error instanceof RequestError) { - throw error; - } - throw new RequestError({ code: 'auth.unauthorized', status: 401 }); + if (forRole) { + assertThat( + roleNames?.includes(forRole), + new RequestError({ code: 'auth.forbidden', status: 403 }) + ); } + ctx.auth = sub; + return next(); }; } diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts new file mode 100644 index 000000000..4b1f1b636 --- /dev/null +++ b/packages/core/src/routes/authn.ts @@ -0,0 +1,45 @@ +import { z } from 'zod'; + +import RequestError from '@/errors/RequestError'; +import koaAuth, { verifyBearerTokenFromRequest } from '@/middleware/koa-auth'; +import koaGuard from '@/middleware/koa-guard'; +import assertThat from '@/utils/assert-that'; + +import { AnonymousRouter } from './types'; + +/** + * Authn stands for authentication. + * This router will have a route `/authn` to authenticate tokens with a general manner. + * For now, we only implement the API for Hasura authentication. + */ +export default function authnRoutes(router: T) { + router.get( + '/authn/hasura', + koaGuard({ + query: z.object({ resource: z.string().min(1) }), + status: [200, 401], + }), + async (ctx, next) => { + const expectedRole = ctx.headers['expected-role']?.toString(); + const { sub, roleNames } = await verifyBearerTokenFromRequest( + ctx.request, + ctx.guard.query.resource + ); + + if (expectedRole) { + assertThat( + roleNames?.includes(expectedRole), + new RequestError({ code: 'auth.expected_role_not_found', status: 401 }) + ); + } + + ctx.body = { + 'X-Hasura-User-Id': sub, + 'X-Hasura-Role': expectedRole, + }; + ctx.status = 200; + + return next(); + } + ); +} diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 7a84674ca..54c83db5d 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -8,6 +8,7 @@ import koaAuth from '@/middleware/koa-auth'; import koaLogSession from '@/middleware/koa-log-session'; import adminUserRoutes from '@/routes/admin-user'; import applicationRoutes from '@/routes/application'; +import authnRoutes from '@/routes/authn'; import connectorRoutes from '@/routes/connector'; import dashboardRoutes from '@/routes/dashboard'; import logRoutes from '@/routes/log'; @@ -46,6 +47,7 @@ const createRouters = (provider: Provider) => { const anonymousRouter: AnonymousRouter = new Router(); wellKnownRoutes(anonymousRouter, provider); statusRoutes(anonymousRouter); + authnRoutes(anonymousRouter); // The swagger.json should contain all API routers. swaggerRoutes(anonymousRouter, [sessionRouter, managementRouter, anonymousRouter]); diff --git a/packages/phrases/src/locales/en/errors.ts b/packages/phrases/src/locales/en/errors.ts index 21c8056fd..1a3c09e8b 100644 --- a/packages/phrases/src/locales/en/errors.ts +++ b/packages/phrases/src/locales/en/errors.ts @@ -4,6 +4,8 @@ const errors = { authorization_token_type_not_supported: 'Authorization type is not supported.', unauthorized: 'Unauthorized. Please check credentials and its scope.', forbidden: 'Forbidden. Please check your user roles and permissions.', + expected_role_not_found: + 'Expected role not found. Please check your user roles and permissions.', jwt_sub_missing: 'Missing `sub` in JWT.', }, guard: { diff --git a/packages/phrases/src/locales/fr/errors.ts b/packages/phrases/src/locales/fr/errors.ts index e23f76af6..82effe31a 100644 --- a/packages/phrases/src/locales/fr/errors.ts +++ b/packages/phrases/src/locales/fr/errors.ts @@ -5,6 +5,8 @@ const errors = { unauthorized: "Non autorisé. Veuillez vérifier les informations d'identification et son champ d'application.", forbidden: "Interdit. Veuillez vérifier vos rôles et autorisations d'utilisateur.", + expected_role_not_found: + 'Expected role not found. Please check your user roles and permissions.', jwt_sub_missing: '`sub` manquant dans JWT.', }, guard: { diff --git a/packages/phrases/src/locales/ko-kr/errors.ts b/packages/phrases/src/locales/ko-kr/errors.ts index 7459c9582..3aa255e70 100644 --- a/packages/phrases/src/locales/ko-kr/errors.ts +++ b/packages/phrases/src/locales/ko-kr/errors.ts @@ -4,6 +4,8 @@ const errors = { authorization_token_type_not_supported: '해당 인증 방법을 지원하지 않아요.', unauthorized: '인증되지 않았어요. 로그인 정보와 범위를 확인해주세요.', forbidden: '접근이 금지되었어요. 로그인 권한와 직책을 확인해주세요.', + expected_role_not_found: + 'Expected role not found. Please check your user roles and permissions.', jwt_sub_missing: 'JWT에서 `sub`를 찾을 수 없어요.', }, guard: { diff --git a/packages/phrases/src/locales/tr-tr/errors.ts b/packages/phrases/src/locales/tr-tr/errors.ts index d5c79ccae..ce4ff4192 100644 --- a/packages/phrases/src/locales/tr-tr/errors.ts +++ b/packages/phrases/src/locales/tr-tr/errors.ts @@ -4,6 +4,8 @@ const errors = { authorization_token_type_not_supported: 'Yetkilendirme tipi desteklenmiyor.', unauthorized: 'Yetki yok. Lütfen kimlik bilgilerini ve kapsamını kontrol edin.', forbidden: 'Yasak. Lütfen kullanıcı rollerinizi ve izinlerinizi kontrol edin.', + expected_role_not_found: + 'Expected role not found. Please check your user roles and permissions.', jwt_sub_missing: 'JWTde `sub` eksik.', }, guard: { diff --git a/packages/phrases/src/locales/zh-cn/errors.ts b/packages/phrases/src/locales/zh-cn/errors.ts index a37ee2548..8f39b6482 100644 --- a/packages/phrases/src/locales/zh-cn/errors.ts +++ b/packages/phrases/src/locales/zh-cn/errors.ts @@ -3,7 +3,8 @@ const errors = { authorization_header_missing: '缺少权限标题', authorization_token_type_not_supported: '权限类型不支持', unauthorized: '未经授权。请检查凭据及其范围。', - forbidden: '禁止访问。请检查用户权限。', + forbidden: '禁止访问。请检查用户 role 与权限。', + expected_role_not_found: '未找到期望的 role。请检查用户 role 与权限。', jwt_sub_missing: 'JWT 缺失 `sub`', }, guard: {