feat(oidc-auth): access and set claims (#711)
* feat(oidc-auth): access and set claims Signed-off-by: Axel Meinhardt <26243798+ameinhardt@users.noreply.github.com> * chore(oidc-auth): add changeset, doc and fix types Signed-off-by: Axel Meinhardt <26243798+ameinhardt@users.noreply.github.com> * chore(oidc-auth): add tests Signed-off-by: Axel Meinhardt <26243798+ameinhardt@users.noreply.github.com> * refactored some types --------- Signed-off-by: Axel Meinhardt <26243798+ameinhardt@users.noreply.github.com> Co-authored-by: Yusuke Wada <yusuke@kamawada.com>pull/716/head
parent
cd99b40177
commit
5675a5fc32
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@hono/oidc-auth': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
define custom scope, access oauth response and set custom session claims
|
|
@ -25,13 +25,13 @@ This middleware requires the following features for the IdP:
|
||||||
|
|
||||||
Here is a list of the IdPs that I have tested:
|
Here is a list of the IdPs that I have tested:
|
||||||
|
|
||||||
| IdP Name | OpenID issuer URL |
|
| IdP Name | OpenID issuer URL |
|
||||||
| ---- | ---- |
|
| ----------- | --------------------------------------------------------- |
|
||||||
| Auth0 | `https://<tenant>.<region>.auth0.com` |
|
| Auth0 | `https://<tenant>.<region>.auth0.com` |
|
||||||
| AWS Cognito | `https://cognito-idp.<region>.amazonaws.com/<userPoolID>` |
|
| AWS Cognito | `https://cognito-idp.<region>.amazonaws.com/<userPoolID>` |
|
||||||
| GitLab | `https://gitlab.com` |
|
| GitLab | `https://gitlab.com` |
|
||||||
| Google | `https://accounts.google.com` |
|
| Google | `https://accounts.google.com` |
|
||||||
| Slack | `https://slack.com` |
|
| Slack | `https://slack.com` |
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
@ -43,22 +43,23 @@ npm i hono @hono/oidc-auth
|
||||||
|
|
||||||
The middleware requires the following environment variables to be set:
|
The middleware requires the following environment variables to be set:
|
||||||
|
|
||||||
| Environment Variable | Description | Default Value |
|
| Environment Variable | Description | Default Value |
|
||||||
| ---- | ---- | ---- |
|
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
|
||||||
| OIDC_AUTH_SECRET | The secret key used for signing the session JWT. It is used to verify the JWT in the cookie and prevent tampering. (Must be at least 32 characters long) | None, must be provided |
|
| OIDC_AUTH_SECRET | The secret key used for signing the session JWT. It is used to verify the JWT in the cookie and prevent tampering. (Must be at least 32 characters long) | None, must be provided |
|
||||||
| OIDC_AUTH_REFRESH_INTERVAL | The interval (in seconds) at which the session should be implicitly refreshed. | 15 * 60 (15 minutes) |
|
| OIDC_AUTH_REFRESH_INTERVAL | The interval (in seconds) at which the session should be implicitly refreshed. | 15 \* 60 (15 minutes) |
|
||||||
| OIDC_AUTH_EXPIRES | The interval (in seconds) after which the session should be considered expired. Once expired, the user will be redirected to the IdP for re-authentication. | 60 * 60 * 24 (1 day) |
|
| OIDC_AUTH_EXPIRES | The interval (in seconds) after which the session should be considered expired. Once expired, the user will be redirected to the IdP for re-authentication. | 60 _ 60 _ 24 (1 day) |
|
||||||
| OIDC_ISSUER | The issuer URL of the OpenID Connect (OIDC) discovery. This URL is used to retrieve the OIDC provider's configuration. | None, must be provided |
|
| OIDC_ISSUER | The issuer URL of the OpenID Connect (OIDC) discovery. This URL is used to retrieve the OIDC provider's configuration. | None, must be provided |
|
||||||
| OIDC_CLIENT_ID | The OAuth 2.0 client ID assigned to your application. This ID is used to identify your application to the OIDC provider. | None, must be provided |
|
| OIDC_CLIENT_ID | The OAuth 2.0 client ID assigned to your application. This ID is used to identify your application to the OIDC provider. | None, must be provided |
|
||||||
| OIDC_CLIENT_SECRET | The OAuth 2.0 client secret assigned to your application. This secret is used to authenticate your application to the OIDC provider. | None, must be provided |
|
| OIDC_CLIENT_SECRET | The OAuth 2.0 client secret assigned to your application. This secret is used to authenticate your application to the OIDC provider. | None, must be provided |
|
||||||
| OIDC_REDIRECT_URI | The URL to which the OIDC provider should redirect the user after authentication. This URL must be registered as a redirect URI in the OIDC provider. | None, must be provided |
|
| OIDC_REDIRECT_URI | The URL to which the OIDC provider should redirect the user after authentication. This URL must be registered as a redirect URI in the OIDC provider. | None, must be provided |
|
||||||
| OIDC_COOKIE_PATH | The path to which the `oidc-auth` cookie is set. Restrict to not send it with every request to your domain | / |
|
| OIDC_SCOPES | The scopes that should be used for the OIDC authentication | The server provided `scopes_supported` |
|
||||||
|
| OIDC_COOKIE_PATH | The path to which the `oidc-auth` cookie is set. Restrict to not send it with every request to your domain | / |
|
||||||
|
|
||||||
## How to Use
|
## How to Use
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { oidcAuthMiddleware, getAuth, revokeSession, processOAuthCallback } from '@hono/oidc-auth';
|
import { oidcAuthMiddleware, getAuth, revokeSession, processOAuthCallback } from '@hono/oidc-auth'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
|
@ -82,7 +83,7 @@ export default app
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth';
|
import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
|
@ -92,15 +93,48 @@ app.get('*', async (c) => {
|
||||||
if (!auth?.email.endsWith('@example.com')) {
|
if (!auth?.email.endsWith('@example.com')) {
|
||||||
return c.text('Unauthorized', 401)
|
return c.text('Unauthorized', 401)
|
||||||
}
|
}
|
||||||
const response = await c.env.ASSETS.fetch(c.req.raw);
|
const response = await c.env.ASSETS.fetch(c.req.raw)
|
||||||
// clone the response to return a response with modifiable headers
|
// clone the response to return a response with modifiable headers
|
||||||
const newResponse = new Response(response.body, response)
|
const newResponse = new Response(response.body, response)
|
||||||
return newResponse
|
return newResponse
|
||||||
});
|
})
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Using original response or additional claims
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { IDToken, OidcAuth, TokenEndpointResponses } from '@hono/oidc-auth';
|
||||||
|
import { processOAuthCallback } from '@hono/oidc-auth';
|
||||||
|
import type { Context, OidcAuthClaims } from 'hono';
|
||||||
|
|
||||||
|
declare module 'hono' {
|
||||||
|
interface OidcAuthClaims {
|
||||||
|
name: string
|
||||||
|
sub: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const oidcClaimsHook = async (orig: OidcAuth | undefined, claims: IDToken | undefined, _response: TokenEndpointResponses): Promise<OidcAuthClaims> => {
|
||||||
|
/*
|
||||||
|
const { someOtherInfo } = await fetch(c.get('oidcAuthorizationServer').userinfo_endpoint, {
|
||||||
|
header: _response.access_token
|
||||||
|
}).then((res) => res.json())
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
name: claims?.name as string ?? orig?.name ?? '',
|
||||||
|
sub: claims?.sub ?? orig?.sub ?? ''
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
...
|
||||||
|
app.get('/callback', async (c) => {
|
||||||
|
c.set('oidcClaimsHook', oidcClaimsHook); // also assure to set before any getAuth(), in case the token is refreshed
|
||||||
|
return processOAuthCallback(c);
|
||||||
|
})
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
If explicit logout is not required, the logout handler can be omitted.
|
If explicit logout is not required, the logout handler can be omitted.
|
||||||
If the middleware is applied to the callback URL, the default callback handling in the middleware can be used, so the explicit callback handling is not required.
|
If the middleware is applied to the callback URL, the default callback handling in the middleware can be used, so the explicit callback handling is not required.
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
* OpenID Connect authentication middleware for hono
|
* OpenID Connect authentication middleware for hono
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context, MiddlewareHandler } from 'hono'
|
import type { Context, MiddlewareHandler, OidcAuthClaims } from 'hono'
|
||||||
import { env } from 'hono/adapter'
|
import { env } from 'hono/adapter'
|
||||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||||
import { createMiddleware } from 'hono/factory'
|
import { createMiddleware } from 'hono/factory'
|
||||||
|
@ -10,13 +10,27 @@ import { HTTPException } from 'hono/http-exception'
|
||||||
import { sign, verify } from 'hono/jwt'
|
import { sign, verify } from 'hono/jwt'
|
||||||
import * as oauth2 from 'oauth4webapi'
|
import * as oauth2 from 'oauth4webapi'
|
||||||
|
|
||||||
|
export type IDToken = oauth2.IDToken
|
||||||
|
export type TokenEndpointResponses =
|
||||||
|
| oauth2.OpenIDTokenEndpointResponse
|
||||||
|
| oauth2.TokenEndpointResponse
|
||||||
|
export type OidcClaimsHook = (
|
||||||
|
orig: OidcAuth | undefined,
|
||||||
|
claims: IDToken | undefined,
|
||||||
|
response: TokenEndpointResponses
|
||||||
|
) => Promise<OidcAuthClaims>
|
||||||
|
|
||||||
declare module 'hono' {
|
declare module 'hono' {
|
||||||
|
export interface OidcAuthClaims {
|
||||||
|
readonly [claim: string]: oauth2.JsonValue | undefined
|
||||||
|
}
|
||||||
interface ContextVariableMap {
|
interface ContextVariableMap {
|
||||||
oidcAuthEnv: OidcAuthEnv
|
oidcAuthEnv: OidcAuthEnv
|
||||||
oidcAuthorizationServer: oauth2.AuthorizationServer
|
oidcAuthorizationServer: oauth2.AuthorizationServer
|
||||||
oidcClient: oauth2.Client
|
oidcClient: oauth2.Client
|
||||||
oidcAuth: OidcAuth | null
|
oidcAuth: OidcAuth | null
|
||||||
oidcAuthJwt: string
|
oidcAuthJwt: string
|
||||||
|
oidcClaimsHook?: OidcClaimsHook
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -25,12 +39,10 @@ const defaultRefreshInterval = 15 * 60 // 15 minutes
|
||||||
const defaultExpirationInterval = 60 * 60 * 24 // 1 day
|
const defaultExpirationInterval = 60 * 60 * 24 // 1 day
|
||||||
|
|
||||||
export type OidcAuth = {
|
export type OidcAuth = {
|
||||||
sub: string
|
|
||||||
email: string
|
|
||||||
rtk: string // refresh token
|
rtk: string // refresh token
|
||||||
rtkexp: number // token expiration time ; refresh token if it's expired
|
rtkexp: number // token expiration time ; refresh token if it's expired
|
||||||
ssnexp: number // session expiration time; if it's expired, revoke session and redirect to IdP
|
ssnexp: number // session expiration time; if it's expired, revoke session and redirect to IdP
|
||||||
}
|
} & OidcAuthClaims
|
||||||
|
|
||||||
type OidcAuthEnv = {
|
type OidcAuthEnv = {
|
||||||
OIDC_AUTH_SECRET: string
|
OIDC_AUTH_SECRET: string
|
||||||
|
@ -40,6 +52,7 @@ type OidcAuthEnv = {
|
||||||
OIDC_CLIENT_ID: string
|
OIDC_CLIENT_ID: string
|
||||||
OIDC_CLIENT_SECRET: string
|
OIDC_CLIENT_SECRET: string
|
||||||
OIDC_REDIRECT_URI: string
|
OIDC_REDIRECT_URI: string
|
||||||
|
OIDC_SCOPES?: string
|
||||||
OIDC_COOKIE_PATH?: string
|
OIDC_COOKIE_PATH?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -114,7 +127,7 @@ export const getClient = (c: Context): oauth2.Client => {
|
||||||
*/
|
*/
|
||||||
export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
|
export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
|
||||||
const env = getOidcAuthEnv(c)
|
const env = getOidcAuthEnv(c)
|
||||||
let auth = c.get('oidcAuth')
|
let auth: Partial<OidcAuth> | null = c.get('oidcAuth')
|
||||||
if (auth === undefined) {
|
if (auth === undefined) {
|
||||||
const session_jwt = getCookie(c, oidcAuthCookieName)
|
const session_jwt = getCookie(c, oidcAuthCookieName)
|
||||||
if (session_jwt === undefined) {
|
if (session_jwt === undefined) {
|
||||||
|
@ -150,11 +163,11 @@ export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
|
||||||
deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' })
|
deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' })
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
auth = await updateAuth(c, auth, result)
|
auth = await updateAuth(c, auth as OidcAuth, result)
|
||||||
}
|
}
|
||||||
c.set('oidcAuth', auth)
|
c.set('oidcAuth', auth as OidcAuth)
|
||||||
}
|
}
|
||||||
return auth
|
return auth as OidcAuth
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -173,15 +186,22 @@ const setAuth = async (
|
||||||
const updateAuth = async (
|
const updateAuth = async (
|
||||||
c: Context,
|
c: Context,
|
||||||
orig: OidcAuth | undefined,
|
orig: OidcAuth | undefined,
|
||||||
response: oauth2.OpenIDTokenEndpointResponse | oauth2.TokenEndpointResponse
|
response: TokenEndpointResponses
|
||||||
): Promise<OidcAuth> => {
|
): Promise<OidcAuth> => {
|
||||||
const env = getOidcAuthEnv(c)
|
const env = getOidcAuthEnv(c)
|
||||||
const claims = oauth2.getValidatedIdTokenClaims(response)
|
const claims = oauth2.getValidatedIdTokenClaims(response)
|
||||||
const authRefreshInterval = Number(env.OIDC_AUTH_REFRESH_INTERVAL!) || defaultRefreshInterval
|
const authRefreshInterval = Number(env.OIDC_AUTH_REFRESH_INTERVAL!) || defaultRefreshInterval
|
||||||
const authExpires = Number(env.OIDC_AUTH_EXPIRES!) || defaultExpirationInterval
|
const authExpires = Number(env.OIDC_AUTH_EXPIRES!) || defaultExpirationInterval
|
||||||
const updated: OidcAuth = {
|
const claimsHook: OidcClaimsHook =
|
||||||
sub: claims?.sub || orig?.sub || '',
|
c.get('oidcClaimsHook') ??
|
||||||
email: (claims?.email as string) || orig?.email || '',
|
(async (orig, claims) => {
|
||||||
|
return {
|
||||||
|
sub: claims?.sub || orig?.sub || '',
|
||||||
|
email: (claims?.email as string) || orig?.email || '',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const updated = {
|
||||||
|
...(await claimsHook(orig, claims, response)),
|
||||||
rtk: response.refresh_token || orig?.rtk || '',
|
rtk: response.refresh_token || orig?.rtk || '',
|
||||||
rtkexp: Math.floor(Date.now() / 1000) + authRefreshInterval,
|
rtkexp: Math.floor(Date.now() / 1000) + authRefreshInterval,
|
||||||
ssnexp: orig?.ssnexp || Math.floor(Date.now() / 1000) + authExpires,
|
ssnexp: orig?.ssnexp || Math.floor(Date.now() / 1000) + authExpires,
|
||||||
|
@ -249,12 +269,17 @@ const generateAuthorizationRequestUrl = async (
|
||||||
throw new HTTPException(500, {
|
throw new HTTPException(500, {
|
||||||
message: 'The supported scopes information is not provided by the IdP',
|
message: 'The supported scopes information is not provided by the IdP',
|
||||||
})
|
})
|
||||||
} else if (as.scopes_supported.indexOf('email') === -1) {
|
} else if (env.OIDC_SCOPES != null) {
|
||||||
throw new HTTPException(500, { message: 'The "email" scope is not supported by the IdP' })
|
for (const scope of env.OIDC_SCOPES.split(' ')) {
|
||||||
} else if (as.scopes_supported.indexOf('offline_access') === -1) {
|
if (as.scopes_supported.indexOf(scope) === -1) {
|
||||||
authorizationRequestUrl.searchParams.set('scope', 'openid email')
|
throw new HTTPException(500, {
|
||||||
|
message: `The '${scope}' scope is not supported by the IdP`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authorizationRequestUrl.searchParams.set('scope', env.OIDC_SCOPES)
|
||||||
} else {
|
} else {
|
||||||
authorizationRequestUrl.searchParams.set('scope', 'openid email offline_access')
|
authorizationRequestUrl.searchParams.set('scope', as.scopes_supported.join(' '))
|
||||||
}
|
}
|
||||||
authorizationRequestUrl.searchParams.set('state', state)
|
authorizationRequestUrl.searchParams.set('state', state)
|
||||||
authorizationRequestUrl.searchParams.set('nonce', nonce)
|
authorizationRequestUrl.searchParams.set('nonce', nonce)
|
||||||
|
|
|
@ -10,6 +10,7 @@ const MOCK_CLIENT_SECRET = 'CLIENT_SECRET_001'
|
||||||
const MOCK_REDIRECT_URI = 'http://localhost/callback'
|
const MOCK_REDIRECT_URI = 'http://localhost/callback'
|
||||||
const MOCK_SUBJECT = 'USER_ID_001'
|
const MOCK_SUBJECT = 'USER_ID_001'
|
||||||
const MOCK_EMAIL = 'user001@example.com'
|
const MOCK_EMAIL = 'user001@example.com'
|
||||||
|
const MOCK_NAME = 'John Doe'
|
||||||
const MOCK_STATE = crypto.randomBytes(16).toString('hex') // 32 bytes
|
const MOCK_STATE = crypto.randomBytes(16).toString('hex') // 32 bytes
|
||||||
const MOCK_NONCE = crypto.randomBytes(16).toString('hex') // 32 bytes
|
const MOCK_NONCE = crypto.randomBytes(16).toString('hex') // 32 bytes
|
||||||
const MOCK_AUTH_SECRET = crypto.randomBytes(16).toString('hex') // 32 bytes
|
const MOCK_AUTH_SECRET = crypto.randomBytes(16).toString('hex') // 32 bytes
|
||||||
|
@ -19,6 +20,8 @@ const MOCK_ID_TOKEN = jwt.sign(
|
||||||
iss: MOCK_ISSUER,
|
iss: MOCK_ISSUER,
|
||||||
aud: MOCK_CLIENT_ID,
|
aud: MOCK_CLIENT_ID,
|
||||||
sub: MOCK_SUBJECT,
|
sub: MOCK_SUBJECT,
|
||||||
|
email: MOCK_EMAIL,
|
||||||
|
name: MOCK_NAME,
|
||||||
exp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
|
exp: Math.floor(Date.now() / 1000) + 10 * 60, // 10 minutes
|
||||||
nonce: MOCK_NONCE,
|
nonce: MOCK_NONCE,
|
||||||
},
|
},
|
||||||
|
@ -153,13 +156,21 @@ jest.unstable_mockModule('oauth4webapi', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const { oidcAuthMiddleware, getAuth, revokeSession } = await import('../src')
|
const { oidcAuthMiddleware, getAuth, processOAuthCallback, revokeSession } = await import('../src')
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
app.get('/logout', async (c) => {
|
app.get('/logout', async (c) => {
|
||||||
await revokeSession(c)
|
await revokeSession(c)
|
||||||
return c.text('OK')
|
return c.text('OK')
|
||||||
})
|
})
|
||||||
|
app.get('/callback-custom', async (c) => {
|
||||||
|
c.set('oidcClaimsHook', async (orig, claims, response) => ({
|
||||||
|
name: (claims?.name as string) ?? orig?.name ?? '',
|
||||||
|
sub: claims?.sub ?? orig?.sub ?? '',
|
||||||
|
token: response.access_token,
|
||||||
|
}))
|
||||||
|
return processOAuthCallback(c)
|
||||||
|
})
|
||||||
app.use('/*', oidcAuthMiddleware())
|
app.use('/*', oidcAuthMiddleware())
|
||||||
app.all('/*', async (c) => {
|
app.all('/*', async (c) => {
|
||||||
const auth = await getAuth(c)
|
const auth = await getAuth(c)
|
||||||
|
@ -173,6 +184,7 @@ beforeEach(() => {
|
||||||
process.env.OIDC_REDIRECT_URI = MOCK_REDIRECT_URI
|
process.env.OIDC_REDIRECT_URI = MOCK_REDIRECT_URI
|
||||||
process.env.OIDC_AUTH_SECRET = MOCK_AUTH_SECRET
|
process.env.OIDC_AUTH_SECRET = MOCK_AUTH_SECRET
|
||||||
process.env.OIDC_AUTH_EXPIRES = MOCK_AUTH_EXPIRES
|
process.env.OIDC_AUTH_EXPIRES = MOCK_AUTH_EXPIRES
|
||||||
|
delete process.env.OIDC_SCOPES
|
||||||
delete process.env.OIDC_COOKIE_PATH
|
delete process.env.OIDC_COOKIE_PATH
|
||||||
})
|
})
|
||||||
describe('oidcAuthMiddleware()', () => {
|
describe('oidcAuthMiddleware()', () => {
|
||||||
|
@ -199,6 +211,22 @@ describe('oidcAuthMiddleware()', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
test('Should redirect to authorization endpoint if session is expired', async () => {
|
test('Should redirect to authorization endpoint if session is expired', async () => {
|
||||||
|
const req = new Request('http://localhost/', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` },
|
||||||
|
})
|
||||||
|
const res = await app.request(req, {}, {})
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res.status).toBe(302)
|
||||||
|
expect(res.headers.get('location')).toMatch(/scope=openid(%20|\+)email(%20|\+)profile&/)
|
||||||
|
expect(res.headers.get('location')).toMatch('access_type=offline&prompt=consent')
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(`state=${MOCK_STATE}`)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(`nonce=${MOCK_NONCE}`)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch('code_verifier=')
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch('continue=http%3A%2F%2Flocalhost%2F')
|
||||||
|
})
|
||||||
|
test('Should use custom scope, if defined', async () => {
|
||||||
|
process.env.OIDC_SCOPES = 'openid email'
|
||||||
const req = new Request('http://localhost/', {
|
const req = new Request('http://localhost/', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` },
|
headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` },
|
||||||
|
@ -213,6 +241,16 @@ describe('oidcAuthMiddleware()', () => {
|
||||||
expect(res.headers.get('set-cookie')).toMatch('code_verifier=')
|
expect(res.headers.get('set-cookie')).toMatch('code_verifier=')
|
||||||
expect(res.headers.get('set-cookie')).toMatch('continue=http%3A%2F%2Flocalhost%2F')
|
expect(res.headers.get('set-cookie')).toMatch('continue=http%3A%2F%2Flocalhost%2F')
|
||||||
})
|
})
|
||||||
|
test('Custom scope is limited to supported scopes', async () => {
|
||||||
|
process.env.OIDC_SCOPES = 'openid email salary'
|
||||||
|
const req = new Request('http://localhost/', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { cookie: `oidc-auth=${MOCK_JWT_EXPIRED_SESSION}` },
|
||||||
|
})
|
||||||
|
const res = await app.request(req, {}, {})
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res.status).toBe(500)
|
||||||
|
})
|
||||||
test('Should redirect to authorization endpoint if no session cookie is found', async () => {
|
test('Should redirect to authorization endpoint if no session cookie is found', async () => {
|
||||||
const req = new Request('http://localhost/', {
|
const req = new Request('http://localhost/', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
@ -251,18 +289,62 @@ describe('processOAuthCallback()', () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const res = await app.request(req, {}, {})
|
const res = await app.request(req, {}, {})
|
||||||
|
const { email, name, sub } = JSON.parse(
|
||||||
|
atob(
|
||||||
|
res.headers
|
||||||
|
.get('set-cookie')
|
||||||
|
?.match(/oidc-auth=[^;]+/)?.[0]
|
||||||
|
?.split('.')[1] as string
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(sub).toBe(MOCK_SUBJECT)
|
||||||
|
expect(email).toBe(MOCK_EMAIL)
|
||||||
|
expect(name).toBeUndefined()
|
||||||
|
expect(res).not.toBeNull()
|
||||||
|
expect(res.status).toBe(302)
|
||||||
|
expect(res.headers.get('location')).toBe('http://localhost/1234')
|
||||||
|
})
|
||||||
|
test('Should respond with customized claims', async () => {
|
||||||
|
const req = new Request(`${MOCK_REDIRECT_URI}-custom?code=1234&state=${MOCK_STATE}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
cookie: `state=${MOCK_STATE}; nonce=${MOCK_NONCE}; code_verifier=1234; continue=http%3A%2F%2Flocalhost%2F1234`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const res = await app.request(req, {}, {})
|
||||||
|
const { email, name, sub } = JSON.parse(
|
||||||
|
atob(
|
||||||
|
res.headers
|
||||||
|
.get('set-cookie')
|
||||||
|
?.match(/oidc-auth=[^;]+/)?.[0]
|
||||||
|
?.split('.')[1] as string
|
||||||
|
)
|
||||||
|
)
|
||||||
|
expect(sub).toBe(MOCK_SUBJECT)
|
||||||
|
expect(email).toBeUndefined()
|
||||||
|
expect(name).toBe(MOCK_NAME)
|
||||||
const path = new URL(MOCK_REDIRECT_URI).pathname
|
const path = new URL(MOCK_REDIRECT_URI).pathname
|
||||||
expect(res).not.toBeNull()
|
expect(res).not.toBeNull()
|
||||||
expect(res.status).toBe(302)
|
expect(res.status).toBe(302)
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`state=; Max-Age=0; Path=${path}($|,)`))
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`))
|
new RegExp(`state=; Max-Age=0; Path=${path}($|,)`)
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`))
|
)
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`))
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=[^;]+; Path=/; HttpOnly; Secure'))
|
new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`)
|
||||||
|
)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
|
new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`)
|
||||||
|
)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
|
new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`)
|
||||||
|
)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
|
new RegExp('oidc-auth=[^;]+; Path=/; HttpOnly; Secure')
|
||||||
|
)
|
||||||
expect(res.headers.get('location')).toBe('http://localhost/1234')
|
expect(res.headers.get('location')).toBe('http://localhost/1234')
|
||||||
})
|
})
|
||||||
test('Should restrict the auth cookie to a given path', async () => {
|
test('Should restrict the auth cookie to a given path', async () => {
|
||||||
const MOCK_COOKIE_PATH = process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication'
|
const MOCK_COOKIE_PATH = (process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication')
|
||||||
process.env.OIDC_REDIRECT_URI = `http://localhost${MOCK_COOKIE_PATH}/callback`
|
process.env.OIDC_REDIRECT_URI = `http://localhost${MOCK_COOKIE_PATH}/callback`
|
||||||
const parentApp = new Hono().route(MOCK_COOKIE_PATH, app)
|
const parentApp = new Hono().route(MOCK_COOKIE_PATH, app)
|
||||||
const path = new URL(process.env.OIDC_REDIRECT_URI).pathname
|
const path = new URL(process.env.OIDC_REDIRECT_URI).pathname
|
||||||
|
@ -275,11 +357,21 @@ describe('processOAuthCallback()', () => {
|
||||||
const res = await parentApp.request(req, {}, {})
|
const res = await parentApp.request(req, {}, {})
|
||||||
expect(res).not.toBeNull()
|
expect(res).not.toBeNull()
|
||||||
expect(res.status).toBe(302)
|
expect(res.status).toBe(302)
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`state=; Max-Age=0; Path=${path}($|,)`))
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`))
|
new RegExp(`state=; Max-Age=0; Path=${path}($|,)`)
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`))
|
)
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`))
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`oidc-auth=[^;]+; Path=${process.env.OIDC_COOKIE_PATH}; HttpOnly; Secure`))
|
new RegExp(`nonce=; Max-Age=0; Path=${path}($|,)`)
|
||||||
|
)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
|
new RegExp(`code_verifier=; Max-Age=0; Path=${path}($|,)`)
|
||||||
|
)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
|
new RegExp(`continue=; Max-Age=0; Path=${path}($|,)`)
|
||||||
|
)
|
||||||
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
|
new RegExp(`oidc-auth=[^;]+; Path=${process.env.OIDC_COOKIE_PATH}; HttpOnly; Secure`)
|
||||||
|
)
|
||||||
expect(res.headers.get('location')).toBe('http://localhost/1234')
|
expect(res.headers.get('location')).toBe('http://localhost/1234')
|
||||||
})
|
})
|
||||||
test('Should return an error if the state parameter does not match', async () => {
|
test('Should return an error if the state parameter does not match', async () => {
|
||||||
|
@ -329,7 +421,7 @@ describe('RevokeSession()', () => {
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)'))
|
expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)'))
|
||||||
})
|
})
|
||||||
test('Should revoke the session of the given path', async () => {
|
test('Should revoke the session of the given path', async () => {
|
||||||
const MOCK_COOKIE_PATH = process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication'
|
const MOCK_COOKIE_PATH = (process.env.OIDC_COOKIE_PATH = '/some/subpath/for/authentication')
|
||||||
const parentApp = new Hono().route(MOCK_COOKIE_PATH, app)
|
const parentApp = new Hono().route(MOCK_COOKIE_PATH, app)
|
||||||
const req = new Request(`http://localhost${MOCK_COOKIE_PATH}/logout`, {
|
const req = new Request(`http://localhost${MOCK_COOKIE_PATH}/logout`, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
@ -338,6 +430,8 @@ describe('RevokeSession()', () => {
|
||||||
const res = await parentApp.request(req, {}, {})
|
const res = await parentApp.request(req, {}, {})
|
||||||
expect(res).not.toBeNull()
|
expect(res).not.toBeNull()
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(res.headers.get('set-cookie')).toMatch(new RegExp(`oidc-auth=; Max-Age=0; Path=${MOCK_COOKIE_PATH}($|,)`))
|
expect(res.headers.get('set-cookie')).toMatch(
|
||||||
|
new RegExp(`oidc-auth=; Max-Age=0; Path=${MOCK_COOKIE_PATH}($|,)`)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue