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:
parent
ab9936c74e
commit
229a786255
4 changed files with 143 additions and 8 deletions
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -32,6 +32,7 @@
|
||||||
"silverhand",
|
"silverhand",
|
||||||
"slonik",
|
"slonik",
|
||||||
"stylelint",
|
"stylelint",
|
||||||
"topbar"
|
"topbar",
|
||||||
|
"hasura"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
106
packages/core/src/routes/authn.test.ts
Normal file
106
packages/core/src/routes/authn.test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in a new issue