feat(oidc-auth): support absolute path for redirect URI (#926)

* feat(oidc-auth): support absolute path for redirect URI

* Apply suggestions from code review

Change variable names to camelCase

Co-authored-by: tempepe <maekawa@tempepe.com>

* Update .changeset/kind-cheetahs-give.md

---------

Co-authored-by: tempepe <maekawa@tempepe.com>
pull/929/head
Yoshio HANAWA 2025-01-09 12:12:21 +09:00 committed by GitHub
parent c7b15e365d
commit 2f716d619d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 97 additions and 12 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/oidc-auth': minor
---
Add support for absolute path in OIDC_REDIRECT_URI and set its default value to '/callback'

View File

@ -51,7 +51,7 @@ The middleware requires the following environment variables to be set:
| 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_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. | `/callback` |
| 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 | / |
| OIDC_COOKIE_NAME | The name of the cookie to be set | `oidc-auth` |
@ -62,7 +62,6 @@ The middleware requires the following environment variables to be set:
```typescript
import { Hono } from 'hono'
import { oidcAuthMiddleware, getAuth, revokeSession, processOAuthCallback } from '@hono/oidc-auth'
const app = new Hono()
app.get('/logout', async (c) => {

View File

@ -34,8 +34,9 @@ declare module 'hono' {
}
}
const defaultOidcAuthCookieName = 'oidc-auth'
const defaultOidcRedirectUri = '/callback'
const defaultOidcAuthCookiePath = '/'
const defaultOidcAuthCookieName = 'oidc-auth'
const defaultRefreshInterval = 15 * 60 // 15 minutes
const defaultExpirationInterval = 60 * 60 * 24 // 1 day
@ -52,7 +53,7 @@ type OidcAuthEnv = {
OIDC_ISSUER: string
OIDC_CLIENT_ID: string
OIDC_CLIENT_SECRET: string
OIDC_REDIRECT_URI: string
OIDC_REDIRECT_URI?: string
OIDC_SCOPES?: string
OIDC_COOKIE_PATH?: string
OIDC_COOKIE_NAME?: string
@ -83,8 +84,13 @@ const getOidcAuthEnv = (c: Context) => {
if (oidcAuthEnv.OIDC_CLIENT_SECRET === undefined) {
throw new HTTPException(500, { message: 'OIDC client secret is not provided' })
}
if (oidcAuthEnv.OIDC_REDIRECT_URI === undefined) {
throw new HTTPException(500, { message: 'OIDC redirect URI is not provided' })
oidcAuthEnv.OIDC_REDIRECT_URI = oidcAuthEnv.OIDC_REDIRECT_URI ?? defaultOidcRedirectUri
if (!oidcAuthEnv.OIDC_REDIRECT_URI.startsWith('/')) {
try {
new URL(oidcAuthEnv.OIDC_REDIRECT_URI)
} catch (e) {
throw new HTTPException(500, { message: 'The OIDC redirect URI is invalid. It must be a full URL or an absolute path' })
}
}
oidcAuthEnv.OIDC_COOKIE_PATH = oidcAuthEnv.OIDC_COOKIE_PATH ?? defaultOidcAuthCookiePath
oidcAuthEnv.OIDC_COOKIE_NAME = oidcAuthEnv.OIDC_COOKIE_NAME ?? defaultOidcAuthCookieName
@ -271,8 +277,9 @@ const generateAuthorizationRequestUrl = async (
const as = await getAuthorizationServer(c)
const client = getClient(c)
const authorizationRequestUrl = new URL(as.authorization_endpoint!)
const redirectUri = new URL(env.OIDC_REDIRECT_URI, c.req.url).toString()
authorizationRequestUrl.searchParams.set('client_id', client.client_id)
authorizationRequestUrl.searchParams.set('redirect_uri', env.OIDC_REDIRECT_URI)
authorizationRequestUrl.searchParams.set('redirect_uri', redirectUri)
authorizationRequestUrl.searchParams.set('response_type', 'code')
if (as.scopes_supported === undefined || as.scopes_supported.length === 0) {
throw new HTTPException(500, {
@ -312,7 +319,7 @@ export const processOAuthCallback = async (c: Context) => {
// Parses the authorization response and validates the state parameter
const state = getCookie(c, 'state')
const path = new URL(env.OIDC_REDIRECT_URI).pathname
const path = new URL(env.OIDC_REDIRECT_URI, c.req.url).pathname
deleteCookie(c, 'state', { path })
const currentUrl: URL = new URL(c.req.url)
const params = oauth2.validateAuthResponse(as, client, currentUrl, state)
@ -333,11 +340,12 @@ export const processOAuthCallback = async (c: Context) => {
if (code === undefined || nonce === undefined || code_verifier === undefined) {
throw new HTTPException(500, { message: 'Missing required parameters / cookies' })
}
const redirectUri = new URL(env.OIDC_REDIRECT_URI, c.req.url).toString()
const result = await exchangeAuthorizationCode(
as,
client,
params,
env.OIDC_REDIRECT_URI,
redirectUri,
nonce,
code_verifier
)
@ -385,14 +393,15 @@ const exchangeAuthorizationCode = async (
export const oidcAuthMiddleware = (): MiddlewareHandler => {
return createMiddleware(async (c, next) => {
const env = getOidcAuthEnv(c)
const uri = c.req.url.split('?')[0]
if (uri === env.OIDC_REDIRECT_URI) {
const uri = new URL(c.req.url)
const redirectUri = new URL(env.OIDC_REDIRECT_URI, c.req.url)
if (uri.pathname === redirectUri.pathname && uri.origin === redirectUri.origin) {
return processOAuthCallback(c)
}
try {
const auth = await getAuth(c)
if (auth === null) {
const path = new URL(env.OIDC_REDIRECT_URI).pathname
const path = new URL(env.OIDC_REDIRECT_URI, c.req.url).pathname
const cookieDomain = env.OIDC_COOKIE_DOMAIN
// Redirect to IdP for login
const state = oauth2.generateRandomState()

View File

@ -281,6 +281,66 @@ describe('oidcAuthMiddleware()', () => {
expect(res.status).toBe(302)
expect(res.headers.get('set-cookie')).toMatch(new RegExp('oidc-auth=; Max-Age=0; Path=/($|,)'))
})
test('Should return an error when OIDC_ISSUER is undefined', async () => {
delete process.env.OIDC_ISSUER
const req = new Request('http://localhost/', {
method: 'GET',
headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
})
const res = await app.request(req, {}, {})
expect(res).not.toBeNull()
expect(res.status).toBe(500)
})
test('Should return an error when OIDC_CLIENT_ID is undefined', async () => {
delete process.env.OIDC_CLIENT_ID
const req = new Request('http://localhost/', {
method: 'GET',
headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
})
const res = await app.request(req, {}, {})
expect(res).not.toBeNull()
expect(res.status).toBe(500)
})
test('Should return an error when OIDC_CLIENT_SECRET is undefined', async () => {
delete process.env.OIDC_CLIENT_SECRET
const req = new Request('http://localhost/', {
method: 'GET',
headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
})
const res = await app.request(req, {}, {})
expect(res).not.toBeNull()
expect(res.status).toBe(500)
})
test('Should return an error when OIDC_REDIRECT_URI is a relative path', async () => {
process.env.OIDC_REDIRECT_URI = '../callback'
const req = new Request('http://localhost/', {
method: 'GET',
headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
})
const res = await app.request(req, {}, {})
expect(res).not.toBeNull()
expect(res.status).toBe(500)
})
test('Should return an error when OIDC_AUTH_SECRET is undefined', async () => {
delete process.env.OIDC_AUTH_SECRET
const req = new Request('http://localhost/', {
method: 'GET',
headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
})
const res = await app.request(req, {}, {})
expect(res).not.toBeNull()
expect(res.status).toBe(500)
})
test('Should return an error when OIDC_AUTH_SECRET is too short', async () => {
process.env.OIDC_AUTH_SECRET = '1234567890123456789012345678901'
const req = new Request('http://localhost/', {
method: 'GET',
headers: { cookie: `oidc-auth=${MOCK_JWT_ACTIVE_SESSION}` },
})
const res = await app.request(req, {}, {})
expect(res).not.toBeNull()
expect(res.status).toBe(500)
})
test('Should Domain attribute of the cookie not set if env value not defined', async () => {
const req = new Request('http://localhost/', {
method: 'GET',
@ -338,6 +398,18 @@ describe('processOAuthCallback()', () => {
expect(res.status).toBe(302)
expect(res.headers.get('location')).toBe('http://localhost/1234')
})
test('Verify default callback path when OIDC_REDIRECT_URI is undefined', async () => {
delete process.env.OIDC_REDIRECT_URI
const req = new Request(`${MOCK_REDIRECT_URI}?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, {}, {})
expect(res).not.toBeNull()
expect(res.status).toBe(302)
})
test('Should respond with customized claims', async () => {
const req = new Request(`${MOCK_REDIRECT_URI}-custom?code=1234&state=${MOCK_STATE}`, {
method: 'GET',