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",
|
||||
"slonik",
|
||||
"stylelint",
|
||||
"topbar"
|
||||
"topbar",
|
||||
"hasura"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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<string>
|
||||
): Promise<TokenInfo> => {
|
||||
const { isProduction, isIntegrationTest, developmentUserId } = envSet.values;
|
||||
const userId = request.headers['development-user-id']?.toString() ?? developmentUserId;
|
||||
|
@ -83,7 +84,10 @@ export default function koaAuth<StateT, ContextT extends IRouterParamContext, Re
|
|||
forRole?: UserRole
|
||||
): MiddlewareType<StateT, WithAuthContext<ContextT>, 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(
|
||||
|
|
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(
|
||||
'/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(
|
||||
|
|
Loading…
Reference in a new issue