|
| 1 | +import axios from 'axios' |
| 2 | +import type { NextAuthOptions } from 'next-auth' |
| 3 | +import Auth0Provider from 'next-auth/providers/auth0' |
| 4 | + |
| 5 | +import { AUTH_CONFIG_SERVER } from '../../../../Config' |
| 6 | +import { IUserMetadata, UserRole } from '../../../../js/types/User' |
| 7 | +import { initializeUserInDB } from '@/js/auth/initializeUserInDb' |
| 8 | + |
| 9 | +const CustomClaimsNS = 'https://tacos.openbeta.io/' |
| 10 | +const CustomClaimUserMetadata = CustomClaimsNS + 'user_metadata' |
| 11 | +const CustomClaimRoles = CustomClaimsNS + 'roles' |
| 12 | + |
| 13 | +if (AUTH_CONFIG_SERVER == null) throw new Error('AUTH_CONFIG_SERVER not defined') |
| 14 | +const { clientSecret, clientId, issuer } = AUTH_CONFIG_SERVER |
| 15 | + |
| 16 | +if (process.env.NODE_ENV === 'production' && clientSecret.length === 0) { |
| 17 | + throw new Error('AUTH0_CLIENT_SECRET is required in production') |
| 18 | +} |
| 19 | + |
| 20 | +export const authOptions: NextAuthOptions = { |
| 21 | + providers: [ |
| 22 | + Auth0Provider({ |
| 23 | + clientId, |
| 24 | + clientSecret, |
| 25 | + issuer, |
| 26 | + authorization: { |
| 27 | + params: { |
| 28 | + audience: 'https://api.openbeta.io', |
| 29 | + scope: 'offline_access access_token_authz openid email profile read:current_user create:current_user_metadata update:current_user_metadata read:stats update:area_attrs' |
| 30 | + } |
| 31 | + }, |
| 32 | + client: { |
| 33 | + token_endpoint_auth_method: clientSecret.length === 0 ? 'none' : 'client_secret_basic' |
| 34 | + } |
| 35 | + }) |
| 36 | + ], |
| 37 | + debug: false, |
| 38 | + events: {}, |
| 39 | + pages: { |
| 40 | + verifyRequest: '/auth/verify-request', |
| 41 | + signIn: '/login' |
| 42 | + }, |
| 43 | + theme: { |
| 44 | + colorScheme: 'light', |
| 45 | + brandColor: '#F15E40', // Hex color code |
| 46 | + logo: 'https://openbeta.io/_next/static/media/openbeta-logo-with-text.3621d038.svg', // Absolute URL to image |
| 47 | + buttonText: '#111826' // Hex color code |
| 48 | + }, |
| 49 | + callbacks: { |
| 50 | + // See https://next-auth.js.org/configuration/callbacks#jwt-callback |
| 51 | + async jwt ({ token, account, profile, user }) { |
| 52 | + /** |
| 53 | + * `account` object is only populated once when the user first logged in. |
| 54 | + */ |
| 55 | + if (account?.access_token != null) { |
| 56 | + token.accessToken = account.access_token |
| 57 | + } |
| 58 | + |
| 59 | + if (account?.refresh_token != null) { |
| 60 | + token.refreshToken = account.refresh_token |
| 61 | + } |
| 62 | + |
| 63 | + /** |
| 64 | + * `account.expires_at` is set in Auth0 custom API |
| 65 | + * Applications -> API -> (OB Climb API) -> Access Token Settings -> Implicit/Hybrid Access Token Lifetime |
| 66 | + */ |
| 67 | + if (account?.expires_at != null) { |
| 68 | + token.expiresAt = account.expires_at |
| 69 | + } |
| 70 | + |
| 71 | + if (profile?.sub != null) { |
| 72 | + token.id = profile.sub |
| 73 | + } |
| 74 | + |
| 75 | + // @ts-expect-error |
| 76 | + if (profile?.[CustomClaimUserMetadata] != null) { |
| 77 | + // null guard needed because profile object is only available once |
| 78 | + // @ts-expect-error |
| 79 | + token.userMetadata = (profile?.[CustomClaimUserMetadata] as IUserMetadata) |
| 80 | + // @ts-expect-error |
| 81 | + const customClaimRoles = profile?.[CustomClaimRoles] as string[] ?? [] |
| 82 | + token.userMetadata.roles = customClaimRoles.map((r: string) => { |
| 83 | + return UserRole[r.toUpperCase() as keyof typeof UserRole] |
| 84 | + }) |
| 85 | + } |
| 86 | + |
| 87 | + if (token?.refreshToken == null || token?.expiresAt == null) { |
| 88 | + throw new Error('Invalid auth data') |
| 89 | + } |
| 90 | + |
| 91 | + if (!(token.userMetadata?.initializedDb ?? false)) { |
| 92 | + const { userMetadata, email, picture: avatar, id: auth0UserId } = token |
| 93 | + const { nick: username, uuid: userUuid } = userMetadata |
| 94 | + const { accessToken } = token |
| 95 | + |
| 96 | + const success = await initializeUserInDB({ auth0UserId, accessToken, username, userUuid, avatar, email }) |
| 97 | + if (success) { |
| 98 | + token.userMetadata.initializedDb = true |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + if ((token.expiresAt as number) < (Date.now() / 1000)) { |
| 103 | + const { accessToken, refreshToken, expiresAt } = await refreshAccessTokenSilently(token.refreshToken as string) |
| 104 | + token.accessToken = accessToken |
| 105 | + token.refreshToken = refreshToken |
| 106 | + token.expiresAt = expiresAt |
| 107 | + } |
| 108 | + |
| 109 | + return token |
| 110 | + }, |
| 111 | + |
| 112 | + async session ({ session, user, token }) { |
| 113 | + if (token.userMetadata == null || |
| 114 | + token?.userMetadata?.uuid == null || token?.userMetadata?.nick == null) { |
| 115 | + // we must have user uuid and nickname for everything to work |
| 116 | + throw new Error('Missing user uuid and nickname from Auth provider') |
| 117 | + } |
| 118 | + |
| 119 | + session.user.metadata = token.userMetadata |
| 120 | + session.accessToken = token.accessToken |
| 121 | + session.id = token.id |
| 122 | + return session |
| 123 | + } |
| 124 | + } |
| 125 | +} |
| 126 | + |
| 127 | +const refreshAccessTokenSilently = async (refreshToken: string): Promise<any> => { |
| 128 | + const response = await axios.request<{ |
| 129 | + access_token: string |
| 130 | + refresh_token: string |
| 131 | + expires_in: number |
| 132 | + }>({ |
| 133 | + method: 'POST', |
| 134 | + url: `${issuer}/oauth/token`, |
| 135 | + headers: { 'content-type': 'application/json' }, |
| 136 | + data: JSON.stringify({ |
| 137 | + grant_type: 'refresh_token', |
| 138 | + client_id: clientId, |
| 139 | + client_secret: clientSecret, |
| 140 | + refresh_token: refreshToken |
| 141 | + }) |
| 142 | + }) |
| 143 | + |
| 144 | + /* eslint-disable @typescript-eslint/naming-convention */ |
| 145 | + const { |
| 146 | + access_token, refresh_token, expires_in |
| 147 | + } = response.data |
| 148 | + |
| 149 | + if (access_token == null || refresh_token == null || expires_in == null) { |
| 150 | + throw new Error('Missing data in refresh token flow') |
| 151 | + } |
| 152 | + |
| 153 | + return { |
| 154 | + accessToken: access_token, |
| 155 | + refreshToken: refresh_token, |
| 156 | + expiresAt: Math.floor((Date.now() / 1000) + expires_in) |
| 157 | + } |
| 158 | +} |
0 commit comments