0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): hasura unauthed access support (#2278)

This commit is contained in:
Gao Sun 2022-10-31 18:18:13 +08:00 committed by GitHub
parent ab9936c74e
commit 229a786255
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 143 additions and 8 deletions

View file

@ -32,6 +32,7 @@
"silverhand", "silverhand",
"slonik", "slonik",
"stylelint", "stylelint",
"topbar" "topbar",
"hasura"
] ]
} }

View file

@ -2,6 +2,7 @@ import type { IncomingHttpHeaders } from 'http';
import { UserRole } from '@logto/schemas'; import { UserRole } from '@logto/schemas';
import { managementResource } from '@logto/schemas/lib/seeds'; import { managementResource } from '@logto/schemas/lib/seeds';
import type { Optional } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials'; import { conditional } from '@silverhand/essentials';
import { jwtVerify } from 'jose'; import { jwtVerify } from 'jose';
import type { MiddlewareType, Request } from 'koa'; import type { MiddlewareType, Request } from 'koa';
@ -49,7 +50,7 @@ type TokenInfo = {
// eslint-disable-next-line complexity // eslint-disable-next-line complexity
export const verifyBearerTokenFromRequest = async ( export const verifyBearerTokenFromRequest = async (
request: Request, request: Request,
resourceIndicator = managementResource.indicator resourceIndicator: Optional<string>
): Promise<TokenInfo> => { ): Promise<TokenInfo> => {
const { isProduction, isIntegrationTest, developmentUserId } = envSet.values; const { isProduction, isIntegrationTest, developmentUserId } = envSet.values;
const userId = request.headers['development-user-id']?.toString() ?? developmentUserId; const userId = request.headers['development-user-id']?.toString() ?? developmentUserId;
@ -83,7 +84,10 @@ export default function koaAuth<StateT, ContextT extends IRouterParamContext, Re
forRole?: UserRole forRole?: UserRole
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> { ): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => { return async (ctx, next) => {
const { sub, clientId, roleNames } = await verifyBearerTokenFromRequest(ctx.request); const { sub, clientId, roleNames } = await verifyBearerTokenFromRequest(
ctx.request,
managementResource.indicator
);
if (forRole) { if (forRole) {
assertThat( assertThat(

View file

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

View file

@ -16,15 +16,39 @@ export default function authnRoutes<T extends AnonymousRouter>(router: T) {
router.get( router.get(
'/authn/hasura', '/authn/hasura',
koaGuard({ koaGuard({
query: z.object({ resource: z.string().min(1) }), query: z.object({ resource: z.string().min(1), unauthorizedRole: z.string().optional() }),
status: [200, 401], status: [200, 401],
}), }),
async (ctx, next) => { async (ctx, next) => {
const { resource, unauthorizedRole } = ctx.guard.query;
const expectedRole = ctx.headers['expected-role']?.toString(); const expectedRole = ctx.headers['expected-role']?.toString();
const { sub, roleNames } = await verifyBearerTokenFromRequest(
ctx.request, const verifyToken = async (expectedResource?: string) => {
ctx.guard.query.resource 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) { if (expectedRole) {
assertThat( assertThat(