-
-
Notifications
You must be signed in to change notification settings - Fork 447
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): add koa oidc auth for profile API
- Loading branch information
Showing
9 changed files
with
282 additions
and
39 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
146 changes: 146 additions & 0 deletions
146
packages/core/src/middleware/koa-auth/koa-oidc-auth.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,146 @@ | ||
import { pickDefault } from '@logto/shared/esm'; | ||
import type { Context } from 'koa'; | ||
import type { IRouterParamContext } from 'koa-router'; | ||
import Provider from 'oidc-provider'; | ||
import Sinon from 'sinon'; | ||
|
||
import RequestError from '#src/errors/RequestError/index.js'; | ||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; | ||
|
||
import type { WithAuthContext } from './index.js'; | ||
|
||
const { jest } = import.meta; | ||
|
||
const provider = new Provider('https://logto.test'); | ||
const mockAccessToken = { | ||
accountId: 'fooUser', | ||
clientId: 'fooClient', | ||
scopes: new Set(['openid']), | ||
}; | ||
|
||
const koaOidcAuth = await pickDefault(import('./koa-oidc-auth.js')); | ||
|
||
afterEach(() => { | ||
Sinon.restore(); | ||
}); | ||
|
||
describe('koaOidcAuth middleware', () => { | ||
const baseCtx = createContextWithRouteParameters(); | ||
|
||
const ctx: WithAuthContext<Context & IRouterParamContext> = { | ||
...baseCtx, | ||
auth: { | ||
type: 'user', | ||
id: '', | ||
}, | ||
}; | ||
|
||
const authHeaderMissingError = new RequestError({ | ||
code: 'auth.authorization_header_missing', | ||
status: 401, | ||
}); | ||
const tokenNotSupportedError = new RequestError( | ||
{ | ||
code: 'auth.authorization_token_type_not_supported', | ||
status: 401, | ||
}, | ||
{ supportedTypes: ['Bearer'] } | ||
); | ||
const unauthorizedError = new RequestError({ code: 'auth.unauthorized', status: 401 }); | ||
const forbiddenError = new RequestError({ code: 'auth.forbidden', status: 403 }); | ||
|
||
const next = jest.fn(); | ||
|
||
beforeEach(() => { | ||
ctx.auth = { | ||
type: 'user', | ||
id: '', | ||
}; | ||
ctx.request = baseCtx.request; | ||
jest.resetModules(); | ||
}); | ||
|
||
it('should set user auth with given sub returned from accessToken', async () => { | ||
ctx.request = { | ||
...ctx.request, | ||
headers: { | ||
authorization: 'Bearer access_token', | ||
}, | ||
}; | ||
Sinon.stub(provider.AccessToken, 'find').resolves(mockAccessToken); | ||
await koaOidcAuth(provider)(ctx, next); | ||
expect(ctx.auth).toEqual({ type: 'user', id: 'fooUser' }); | ||
}); | ||
|
||
it('expect to throw if authorization header is missing', async () => { | ||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(authHeaderMissingError); | ||
}); | ||
|
||
it('expect to throw if authorization header token type not recognized ', async () => { | ||
ctx.request = { | ||
...ctx.request, | ||
headers: { | ||
authorization: 'dummy access_token', | ||
}, | ||
}; | ||
|
||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(tokenNotSupportedError); | ||
}); | ||
|
||
it('expect to throw if access token is not found', async () => { | ||
ctx.request = { | ||
...ctx.request, | ||
headers: { | ||
authorization: 'Bearer access_token', | ||
}, | ||
}; | ||
Sinon.stub(provider.AccessToken, 'find').resolves(); | ||
|
||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError); | ||
}); | ||
|
||
it('expect to throw if sub is missing', async () => { | ||
ctx.request = { | ||
...ctx.request, | ||
headers: { | ||
authorization: 'Bearer access_token', | ||
}, | ||
}; | ||
Sinon.stub(provider.AccessToken, 'find').resolves({ | ||
...mockAccessToken, | ||
accountId: undefined, | ||
}); | ||
|
||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError); | ||
}); | ||
|
||
it('expect to throw if access token is issued to the client', async () => { | ||
ctx.request = { | ||
...ctx.request, | ||
headers: { | ||
authorization: 'Bearer access_token', | ||
}, | ||
}; | ||
Sinon.stub(provider.AccessToken, 'find').resolves({ | ||
...mockAccessToken, | ||
accountId: 'fooClient', | ||
}); | ||
|
||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(unauthorizedError); | ||
}); | ||
|
||
it('expect to throw if access token does not have openid scope', async () => { | ||
ctx.request = { | ||
...ctx.request, | ||
headers: { | ||
authorization: 'Bearer access_token', | ||
}, | ||
}; | ||
Sinon.stub(provider.AccessToken, 'find').resolves({ | ||
...mockAccessToken, | ||
scopes: new Set(['foo']), | ||
}); | ||
|
||
await expect(koaOidcAuth(provider)(ctx, next)).rejects.toMatchError(forbiddenError); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import type { MiddlewareType } from 'koa'; | ||
import type { IRouterParamContext } from 'koa-router'; | ||
import type Provider from 'oidc-provider'; | ||
|
||
import RequestError from '#src/errors/RequestError/index.js'; | ||
import assertThat from '#src/utils/assert-that.js'; | ||
|
||
import { type WithAuthContext } from './types.js'; | ||
import { extractBearerTokenFromHeaders } from './utils.js'; | ||
|
||
/** | ||
* Auth middleware for OIDC opaque token | ||
*/ | ||
export default function koaOidcAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>( | ||
provider: Provider | ||
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> { | ||
const authMiddleware: MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> = async ( | ||
ctx, | ||
next | ||
) => { | ||
const { request } = ctx; | ||
const accessTokenValue = extractBearerTokenFromHeaders(request.headers); | ||
const accessToken = await provider.AccessToken.find(accessTokenValue); | ||
|
||
assertThat(accessToken, new RequestError({ code: 'auth.unauthorized', status: 401 })); | ||
|
||
const { accountId, clientId, scopes } = accessToken; | ||
assertThat(accountId, new RequestError({ code: 'auth.unauthorized', status: 401 })); | ||
|
||
// The access token must be issued to a user, not to the client | ||
assertThat( | ||
accountId !== clientId, | ||
new RequestError({ code: 'auth.unauthorized', status: 401 }) | ||
); | ||
|
||
assertThat(scopes.has('openid'), new RequestError({ code: 'auth.forbidden', status: 403 })); | ||
|
||
ctx.auth = { | ||
type: 'user', | ||
id: accountId, | ||
}; | ||
|
||
return next(); | ||
}; | ||
|
||
return authMiddleware; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
import { type IRouterParamContext } from 'koa-router'; | ||
|
||
type Auth = { | ||
type: 'user' | 'app'; | ||
id: string; | ||
}; | ||
|
||
export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamContext> = | ||
ContextT & { | ||
auth: Auth; | ||
}; | ||
|
||
export type TokenInfo = { | ||
sub: string; | ||
clientId: unknown; | ||
scopes: string[]; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { z } from 'zod'; | ||
|
||
import koaGuard from '#src/middleware/koa-guard.js'; | ||
|
||
import koaOidcAuth from '../../middleware/koa-auth/koa-oidc-auth.js'; | ||
import type { ProfileRouter, RouterInitArgs } from '../types.js'; | ||
|
||
/** | ||
* Authn stands for authentication. | ||
* This router will have a route `/authn` to authenticate tokens with a general manner. | ||
*/ | ||
export default function profileRoutes<T extends ProfileRouter>( | ||
...[router, { provider }]: RouterInitArgs<T> | ||
) { | ||
router.use(koaOidcAuth(provider)); | ||
|
||
// TODO: test route only, will implement a better one later | ||
router.get( | ||
'/profile', | ||
koaGuard({ | ||
response: z.object({ | ||
sub: z.string(), | ||
}), | ||
status: [200], | ||
}), | ||
async (ctx, next) => { | ||
ctx.body = { | ||
sub: ctx.auth.id, | ||
}; | ||
ctx.status = 200; | ||
|
||
return next(); | ||
} | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters