Skip to content

Commit

Permalink
feat: use dedicated client credentials issued token
Browse files Browse the repository at this point in the history
  • Loading branch information
emmanuelgautier committed Sep 3, 2024
1 parent b7b0911 commit 584b445
Show file tree
Hide file tree
Showing 6 changed files with 1,634 additions and 2,617 deletions.
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Use openssl rand -base64 33 to generate a secret
AUTH_SECRET=secret

AUTH_CLIENT_ISSUER=https://oauth.cerberauth.com
AUTH_CLIENT_ID=
AUTH_CLIENT_SECRET=

AUTH_API_CLIENT_ID=
AUTH_API_CLIENT_SECRET=
AUTH_API_SCOPE=dynamic-client:write
83 changes: 46 additions & 37 deletions app/api/testid/clients/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { auth } from '@/auth'
import { auth, tokenIssuer } from '@/auth'

import { GrantTypes, TokenEndpointAuthMethods } from '@/lib/consts'
import { getToken } from '@/lib/token'

export const runtime = 'edge'

Expand All @@ -16,45 +17,53 @@ export const POST = auth(async (req) => {
return new Response(null, { status: 400 })
}

if (!(req.auth?.token && req.auth?.user?.id)) {
if (!req.auth?.user?.id) {
return new Response(null, { status: 401 })
}

const method = clientData.tokenEndpointAuthMethod || TokenEndpointAuthMethods.clientSecretBasic;
const response = await fetch('https://testid.cerberauth.com/oauth2/register', {
method: 'POST',
headers: {
'Authorization': `Bearer ${req.auth.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_types: clientData.grantTypes || [GrantTypes.authorizationCode],
token_endpoint_auth_method: method,

client_name: clientData.name,
allowed_cors_origins: clientData.allowedCorsOrigins,
scope: clientData.scopes.join(' '),
audience: clientData.audiences,
redirect_uris: clientData.redirectUris,
post_logout_redirect_uris: clientData.postLogoutRedirectUris,

owner: req.auth.user.id,
contacts: clientData.contacts,
client_uri: clientData.uri,
policy_uri: clientData.policyUri,
tos_uri: clientData.tosUri,
logo_uri: clientData.logoUri,
}),
})
if (!response.ok) {
return new Response(null, { status: response.status })
}
const data = await response.json<OpenIDConnectProviderResponse>()
const responseData: TestIdClient = {
clientId: data.client_id,
clientSecret: data.client_secret,
client: clientData,
}
try {
const token = await getToken()
const response = await fetch('https://testid.cerberauth.com/oauth2/register', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_types: clientData.grantTypes || [GrantTypes.authorizationCode],
token_endpoint_auth_method: method,

client_name: clientData.name,
allowed_cors_origins: clientData.allowedCorsOrigins,
scope: clientData.scopes.join(' '),
audience: clientData.audiences,
redirect_uris: clientData.redirectUris,
post_logout_redirect_uris: clientData.postLogoutRedirectUris,

return Response.json(responseData, { status: 201 })
owner: req.auth.user.id,
contacts: clientData.contacts,
client_uri: clientData.uri,
policy_uri: clientData.policyUri,
tos_uri: clientData.tosUri,
logo_uri: clientData.logoUri,
}),
})
if (!response.ok) {
console.error('TestID client registration error', response.status, await response.json())
return new Response(null, { status: 400 })
}

const data = await response.json<OpenIDConnectProviderResponse>()
const responseData: TestIdClient = {
clientId: data.client_id,
clientSecret: data.client_secret,
client: clientData,
}

return Response.json(responseData, { status: 201 })
} catch (err) {
console.error('TestID client registration error', err)
return new Response(null, { status: 500 })
}
})
21 changes: 14 additions & 7 deletions auth.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import NextAuth from 'next-auth'

export const tokenIssuer = process.env.AUTH_CLIENT_ISSUER || 'https://oauth.cerberauth.com'

export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [{
id: 'cerberauth',
name: 'CerberAuth',
issuer: 'https://oauth.cerberauth.com',
issuer: tokenIssuer,
type: 'oidc',
clientId: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET,
checks: ['pkce', 'state'],
checks: ['pkce', 'state', 'nonce'],
authorization: {
params: { scope: 'openid profile email dynamic-client:write' }
params: { scope: 'openid profile email offline_access' }
},
idToken: true,
}],
session: { strategy: 'jwt' },
callbacks: {
Expand All @@ -22,9 +25,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({

return false
},
jwt: async ({ token, account }) => {
if (account?.access_token) {
token.accessToken = account.access_token
jwt: ({ token, profile }) => {
if (profile?.sub && profile?.email) {
return {
sub: profile.sub,
name: profile.name,
email: profile.email,
picture: profile.picture,
}
}

return token
Expand All @@ -38,7 +46,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
email: token.email,
image: token.picture,
},
token: token.accessToken,
}
},
}
Expand Down
59 changes: 59 additions & 0 deletions lib/token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { tokenIssuer } from '@/auth'
import { type AuthorizationServer, type Client, clientCredentialsGrantRequest, discoveryRequest, isOAuth2Error, OAuth2TokenEndpointResponse, parseWwwAuthenticateChallenges, processClientCredentialsResponse, processDiscoveryResponse, type WWWAuthenticateChallenge } from 'oauth4webapi'

const issuer = new URL(tokenIssuer)
const client: Client = {
client_id: process.env.AUTH_API_CLIENT_ID!,
client_secret: process.env.AUTH_API_CLIENT_SECRET,
token_endpoint_auth_method: 'client_secret_basic',
}

let authorizationServer: AuthorizationServer | null = null
async function getAs(): Promise<AuthorizationServer> {
if (authorizationServer) {
return authorizationServer
}

const _authorizationServer = await discoveryRequest(issuer, { algorithm: 'oidc' })
.then(response => processDiscoveryResponse(issuer, response))
authorizationServer = _authorizationServer
return _authorizationServer
}

let expiresAt: Date | null = null
let token: OAuth2TokenEndpointResponse | null = null
export async function getToken(): Promise<string> {
if (token?.access_token && expiresAt && expiresAt > new Date()) {
return token.access_token
}

const as = await getAs()
const parameters = new URLSearchParams()
if (process.env.AUTH_API_SCOPE) {
parameters.set('scope', process.env.AUTH_API_SCOPE)
}

const response = await clientCredentialsGrantRequest(as, client, parameters)
let challenges: WWWAuthenticateChallenge[] | undefined
if ((challenges = parseWwwAuthenticateChallenges(response))) {
for (const challenge of challenges) {
console.error('WWW-Authenticate Challenge', challenge)
}
throw new Error()
}

const result = await processClientCredentialsResponse(as, client, response)
if (isOAuth2Error(result)) {
console.error('OAuth2 Error', result)
throw new Error()
}

if (!result.access_token || !result.expires_in) {
console.error('Invalid Token Endpoint Response', result)
throw new Error()
}

token = result
expiresAt = new Date(Date.now() + (result.expires_in * 1000) - 100)
return result.access_token
}
Loading

0 comments on commit 584b445

Please sign in to comment.