Skip to content

Commit

Permalink
feat(oidc-auth): access and set claims (#711)
Browse files Browse the repository at this point in the history
* feat(oidc-auth): access and set claims

Signed-off-by: Axel Meinhardt <[email protected]>

* chore(oidc-auth): add changeset, doc and fix types

Signed-off-by: Axel Meinhardt <[email protected]>

* chore(oidc-auth): add tests

Signed-off-by: Axel Meinhardt <[email protected]>

* refactored some types

---------

Signed-off-by: Axel Meinhardt <[email protected]>
Co-authored-by: Yusuke Wada <[email protected]>
  • Loading branch information
ameinhardt and yusukebe authored Aug 26, 2024
1 parent cd99b40 commit 5675a5f
Show file tree
Hide file tree
Showing 4 changed files with 209 additions and 51 deletions.
5 changes: 5 additions & 0 deletions .changeset/gorgeous-berries-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@hono/oidc-auth': minor
---

define custom scope, access oauth response and set custom session claims
74 changes: 54 additions & 20 deletions packages/oidc-auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ This middleware requires the following features for the IdP:

Here is a list of the IdPs that I have tested:

| IdP Name | OpenID issuer URL |
| ---- | ---- |
| Auth0 | `https://<tenant>.<region>.auth0.com` |
| IdP Name | OpenID issuer URL |
| ----------- | --------------------------------------------------------- |
| Auth0 | `https://<tenant>.<region>.auth0.com` |
| AWS Cognito | `https://cognito-idp.<region>.amazonaws.com/<userPoolID>` |
| GitLab | `https://gitlab.com` |
| Google | `https://accounts.google.com` |
| Slack | `https://slack.com` |
| GitLab | `https://gitlab.com` |
| Google | `https://accounts.google.com` |
| Slack | `https://slack.com` |

## Installation

Expand All @@ -43,22 +43,23 @@ npm i hono @hono/oidc-auth

The middleware requires the following environment variables to be set:

| 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_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_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_COOKIE_PATH | The path to which the `oidc-auth` cookie is set. Restrict to not send it with every request to your domain | / |
| 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_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_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_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

```typescript
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()

Expand All @@ -82,7 +83,7 @@ export default app

```typescript
import { Hono } from 'hono'
import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth';
import { oidcAuthMiddleware, getAuth } from '@hono/oidc-auth'

const app = new Hono()

Expand All @@ -92,15 +93,48 @@ app.get('*', async (c) => {
if (!auth?.email.endsWith('@example.com')) {
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
const newResponse = new Response(response.body, response)
return newResponse
});
})

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:
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.
Expand Down
59 changes: 42 additions & 17 deletions packages/oidc-auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,35 @@
* 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 { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { createMiddleware } from 'hono/factory'
import { HTTPException } from 'hono/http-exception'
import { sign, verify } from 'hono/jwt'
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' {
export interface OidcAuthClaims {
readonly [claim: string]: oauth2.JsonValue | undefined
}
interface ContextVariableMap {
oidcAuthEnv: OidcAuthEnv
oidcAuthorizationServer: oauth2.AuthorizationServer
oidcClient: oauth2.Client
oidcAuth: OidcAuth | null
oidcAuthJwt: string
oidcClaimsHook?: OidcClaimsHook
}
}

Expand All @@ -25,12 +39,10 @@ const defaultRefreshInterval = 15 * 60 // 15 minutes
const defaultExpirationInterval = 60 * 60 * 24 // 1 day

export type OidcAuth = {
sub: string
email: string
rtk: string // refresh token
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
}
} & OidcAuthClaims

type OidcAuthEnv = {
OIDC_AUTH_SECRET: string
Expand All @@ -40,6 +52,7 @@ type OidcAuthEnv = {
OIDC_CLIENT_ID: string
OIDC_CLIENT_SECRET: string
OIDC_REDIRECT_URI: string
OIDC_SCOPES?: string
OIDC_COOKIE_PATH?: string
}

Expand Down Expand Up @@ -114,7 +127,7 @@ export const getClient = (c: Context): oauth2.Client => {
*/
export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
const env = getOidcAuthEnv(c)
let auth = c.get('oidcAuth')
let auth: Partial<OidcAuth> | null = c.get('oidcAuth')
if (auth === undefined) {
const session_jwt = getCookie(c, oidcAuthCookieName)
if (session_jwt === undefined) {
Expand Down Expand Up @@ -150,11 +163,11 @@ export const getAuth = async (c: Context): Promise<OidcAuth | null> => {
deleteCookie(c, oidcAuthCookieName, { path: env.OIDC_COOKIE_PATH ?? '/' })
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
}

/**
Expand All @@ -173,15 +186,22 @@ const setAuth = async (
const updateAuth = async (
c: Context,
orig: OidcAuth | undefined,
response: oauth2.OpenIDTokenEndpointResponse | oauth2.TokenEndpointResponse
response: TokenEndpointResponses
): Promise<OidcAuth> => {
const env = getOidcAuthEnv(c)
const claims = oauth2.getValidatedIdTokenClaims(response)
const authRefreshInterval = Number(env.OIDC_AUTH_REFRESH_INTERVAL!) || defaultRefreshInterval
const authExpires = Number(env.OIDC_AUTH_EXPIRES!) || defaultExpirationInterval
const updated: OidcAuth = {
sub: claims?.sub || orig?.sub || '',
email: (claims?.email as string) || orig?.email || '',
const claimsHook: OidcClaimsHook =
c.get('oidcClaimsHook') ??
(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 || '',
rtkexp: Math.floor(Date.now() / 1000) + authRefreshInterval,
ssnexp: orig?.ssnexp || Math.floor(Date.now() / 1000) + authExpires,
Expand Down Expand Up @@ -249,12 +269,17 @@ const generateAuthorizationRequestUrl = async (
throw new HTTPException(500, {
message: 'The supported scopes information is not provided by the IdP',
})
} else if (as.scopes_supported.indexOf('email') === -1) {
throw new HTTPException(500, { message: 'The "email" scope is not supported by the IdP' })
} else if (as.scopes_supported.indexOf('offline_access') === -1) {
authorizationRequestUrl.searchParams.set('scope', 'openid email')
} else if (env.OIDC_SCOPES != null) {
for (const scope of env.OIDC_SCOPES.split(' ')) {
if (as.scopes_supported.indexOf(scope) === -1) {
throw new HTTPException(500, {
message: `The '${scope}' scope is not supported by the IdP`,
})
}
}
authorizationRequestUrl.searchParams.set('scope', env.OIDC_SCOPES)
} 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('nonce', nonce)
Expand Down
Loading

0 comments on commit 5675a5f

Please sign in to comment.