From 229a7862552d44634543117be67872a01b4e165f Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Mon, 31 Oct 2022 18:18:13 +0800 Subject: [PATCH] feat(core): hasura unauthed access support (#2278) --- .vscode/settings.json | 3 +- packages/core/src/middleware/koa-auth.ts | 8 +- packages/core/src/routes/authn.test.ts | 106 +++++++++++++++++++++++ packages/core/src/routes/authn.ts | 34 ++++++-- 4 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/routes/authn.test.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index c85102924..10453f7bc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -32,6 +32,7 @@ "silverhand", "slonik", "stylelint", - "topbar" + "topbar", + "hasura" ] } diff --git a/packages/core/src/middleware/koa-auth.ts b/packages/core/src/middleware/koa-auth.ts index eda71e3bb..07e3b1778 100644 --- a/packages/core/src/middleware/koa-auth.ts +++ b/packages/core/src/middleware/koa-auth.ts @@ -2,6 +2,7 @@ import type { IncomingHttpHeaders } from 'http'; import { UserRole } from '@logto/schemas'; import { managementResource } from '@logto/schemas/lib/seeds'; +import type { Optional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials'; import { jwtVerify } from 'jose'; import type { MiddlewareType, Request } from 'koa'; @@ -49,7 +50,7 @@ type TokenInfo = { // eslint-disable-next-line complexity export const verifyBearerTokenFromRequest = async ( request: Request, - resourceIndicator = managementResource.indicator + resourceIndicator: Optional ): Promise => { const { isProduction, isIntegrationTest, developmentUserId } = envSet.values; const userId = request.headers['development-user-id']?.toString() ?? developmentUserId; @@ -83,7 +84,10 @@ export default function koaAuth, ResponseBodyT> { return async (ctx, next) => { - const { sub, clientId, roleNames } = await verifyBearerTokenFromRequest(ctx.request); + const { sub, clientId, roleNames } = await verifyBearerTokenFromRequest( + ctx.request, + managementResource.indicator + ); if (forRole) { assertThat( diff --git a/packages/core/src/routes/authn.test.ts b/packages/core/src/routes/authn.test.ts new file mode 100644 index 000000000..4a856693c --- /dev/null +++ b/packages/core/src/routes/authn.test.ts @@ -0,0 +1,106 @@ +import RequestError from '@/errors/RequestError'; +import * as functions from '@/middleware/koa-auth'; +import { createRequester } from '@/utils/test-utils'; + +import authnRoutes from './authn'; + +describe('authn route for Hasura', () => { + const request = createRequester({ anonymousRoutes: authnRoutes }); + const mockUserId = 'foo'; + const mockExpectedRole = 'some_role'; + const mockUnauthorizedRole = 'V'; + const keys = Object.freeze({ + expectedRole: 'Expected-Role', + hasuraUserId: 'X-Hasura-User-Id', + hasuraRole: 'X-Hasura-Role', + }); + + describe('with successful verification', () => { + beforeEach(() => { + jest.spyOn(functions, 'verifyBearerTokenFromRequest').mockResolvedValue({ + clientId: 'ok', + sub: mockUserId, + roleNames: [mockExpectedRole], + }); + }); + + it('has expected role', async () => { + const response = await request + .get('/authn/hasura') + .query({ resource: 'https://api.logto.io' }) + .set(keys.expectedRole, mockExpectedRole); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + [keys.hasuraUserId]: mockUserId, + [keys.hasuraRole]: mockExpectedRole, + }); + }); + + it('throws 401 if no expected role present', async () => { + const response = await request + .get('/authn/hasura') + .query({ resource: 'https://api.logto.io' }) + .set(keys.expectedRole, mockExpectedRole + '1'); + expect(response.status).toEqual(401); + }); + + it('falls back to unauthorized role if no expected role present', async () => { + const response = await request + .get('/authn/hasura') + .query({ resource: 'https://api.logto.io', unauthorizedRole: mockUnauthorizedRole }) + .set(keys.expectedRole, mockExpectedRole + '1'); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + [keys.hasuraUserId]: mockUserId, + [keys.hasuraRole]: mockUnauthorizedRole, + }); + }); + }); + + describe('with failed verification', () => { + beforeEach(() => { + jest + .spyOn(functions, 'verifyBearerTokenFromRequest') + .mockImplementation(async (_, resource) => { + if (resource) { + throw new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }); + } + + return { clientId: 'not ok', sub: mockUserId }; + }); + }); + + it('throws 401 if no unauthorized role presents', async () => { + const response = await request + .get('/authn/hasura') + .query({ resource: 'https://api.logto.io' }) + .set(keys.expectedRole, mockExpectedRole); + expect(response.status).toEqual(401); + }); + + it('falls back to unauthorized role with user id if no expected resource present', async () => { + const response = await request + .get('/authn/hasura') + .query({ resource: 'https://api.logto.io', unauthorizedRole: mockUnauthorizedRole }) + .set(keys.expectedRole, mockExpectedRole); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + [keys.hasuraUserId]: mockUserId, + [keys.hasuraRole]: mockUnauthorizedRole, + }); + }); + + it('falls back to unauthorized role if JWT is invalid', async () => { + jest + .spyOn(functions, 'verifyBearerTokenFromRequest') + .mockRejectedValue(new RequestError({ code: 'auth.jwt_sub_missing', status: 401 })); + const response = await request + .get('/authn/hasura') + .query({ resource: 'https://api.logto.io', unauthorizedRole: mockUnauthorizedRole }); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + [keys.hasuraRole]: mockUnauthorizedRole, + }); + }); + }); +}); diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts index d6a99b439..aa523a84a 100644 --- a/packages/core/src/routes/authn.ts +++ b/packages/core/src/routes/authn.ts @@ -16,15 +16,39 @@ export default function authnRoutes(router: T) { router.get( '/authn/hasura', koaGuard({ - query: z.object({ resource: z.string().min(1) }), + query: z.object({ resource: z.string().min(1), unauthorizedRole: z.string().optional() }), status: [200, 401], }), async (ctx, next) => { + const { resource, unauthorizedRole } = ctx.guard.query; const expectedRole = ctx.headers['expected-role']?.toString(); - const { sub, roleNames } = await verifyBearerTokenFromRequest( - ctx.request, - ctx.guard.query.resource - ); + + const verifyToken = async (expectedResource?: string) => { + try { + return await verifyBearerTokenFromRequest(ctx.request, expectedResource); + } catch { + return { + sub: undefined, + roleNames: undefined, + }; + } + }; + + const { sub, roleNames } = await verifyToken(resource); + + if (unauthorizedRole && (!expectedRole || !roleNames?.includes(expectedRole))) { + ctx.body = { + 'X-Hasura-User-Id': + sub ?? + // When the previous token verification throws, the reason could be resource mismatch. + // So we verify the token again with no resource provided. + (await verifyToken().then(({ sub }) => sub)), + 'X-Hasura-Role': unauthorizedRole, + }; + ctx.status = 200; + + return next(); + } if (expectedRole) { assertThat(