Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 248 additions & 13 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"jsonwebtoken": "^8.5.1",
"nodemailer": "^6.4.16",
"oauth": "^0.9.15",
"openid-client": "^4.2.2",
"preact": "^10.4.1",
"preact-render-to-string": "^5.1.7",
"querystring": "^0.2.0",
Expand Down Expand Up @@ -99,7 +100,8 @@
"ignore": [
"test/",
"pages/",
"components/"
"components/",
"**/*/*.d.ts"
]
}
}
18 changes: 8 additions & 10 deletions src/lib/errors.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
class UnknownError extends Error {
export class UnknownError extends Error {
constructor (message) {
super(message)
this.name = 'UnknownError'
this.message = message
}

toJSON () {
Expand All @@ -16,26 +15,25 @@ class UnknownError extends Error {
}
}

class CreateUserError extends UnknownError {
export class CreateUserError extends UnknownError {
constructor (message) {
super(message)
this.name = 'CreateUserError'
this.message = message
}
}

// Thrown when an Email address is already associated with an account
// but the user is trying an OAuth account that is not linked to it.
class AccountNotLinkedError extends UnknownError {
export class AccountNotLinkedError extends UnknownError {
constructor (message) {
super(message)
this.name = 'AccountNotLinkedError'
this.message = message
}
}

module.exports = {
UnknownError,
CreateUserError,
AccountNotLinkedError
export class OAuthCallbackHandlerError extends UnknownError {
constructor (message) {
super(message)
this.name = 'OAuthCallbackHandlerError'
}
}
7 changes: 6 additions & 1 deletion src/providers/github.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/**
* @param {import("next-auth").Provider} options
* @returns {import("next-auth").Provider}
*/
export default (options) => {
return {
id: 'github',
Expand All @@ -8,14 +12,15 @@ export default (options) => {
accessTokenUrl: 'https://github.com/login/oauth/access_token',
authorizationUrl: 'https://github.com/login/oauth/authorize',
profileUrl: 'https://api.github.com/user',
profile: (profile) => {
profile (profile) {
return {
id: profile.id,
name: profile.name || profile.login,
email: profile.email,
image: profile.avatar_url
}
},
verifications: ['state', 'pkce'],
...options
}
}
60 changes: 60 additions & 0 deletions src/server/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {NextApiRequest} from "next"
import {defaultCookies} from "./lib/cookie"

export interface Provider {
id: string
name: string
type: string
clientId: string
clientSecret: string
version?: string
scope?: string
accessTokenUrl?: string
authorizationUrl?: string
profileUrl?: string
profile?(profile: {}): Promise<{}>
verifications?: ("state" | "pkce")[]
}

export interface NextApiRequestWithOptions extends NextApiRequest {
options: {
debug?: boolean
theme?: 'auto' | 'light' | 'dark'
adapter?: {}
provider?: Provider
baseUrl: string
basePath: string
secret: string
cookies: ReturnType<typeof defaultCookies>
callbackUrl: string
pages: {}
jwt: {
secret: string,
maxAge: number,
async encode(): any,
async decode(): any,
encryption: boolean
}
events: {
signIn?(): Promise<void>
signOut?(): Promise<void>
createUser?(): Promise<void>
updateUser?(): Promise<void>
linkAccount?(): Promise<void>
session?(): Promise<void>
erro?(): Promise<void>
}
callbacks: {
signIn(): Promise<string | false>
jwt(): Promise<{}>
session(): Promise<{}>
redirect(): Promise<string>
}
session: {
jwt: boolean,
maxAge: number
updateAge: number
}
csrfToken: string
}
}
21 changes: 21 additions & 0 deletions src/server/lib/oauth/callback.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export interface HandleOAuthCallbackResultObject {
/**
* Profile data returned by the `profile` provider option
* @docs https://next-auth.js.org/configuration/providers#oauth-provider-options
*/
profile?: Record<string, any>
/** Contains tokens (access_token, refresh_token, id_token), and ... */
account?: {
accessToken: string
accessTokenExpires: Date | string | null
refreshToken?: string
idToken?: string
[key: string]: any
}
/** Raw profile returned from the OAuth provider */
OAuthProfile?: {
/** Returned by the Apple provider */
user?: Record<string, any>
[key: string]: any
}
}
204 changes: 54 additions & 150 deletions src/server/lib/oauth/callback.js
Original file line number Diff line number Diff line change
@@ -1,169 +1,73 @@
import { createHash } from 'crypto'
import { decode as jwtDecode } from 'jsonwebtoken'
import oAuthClient from './client'
import getOAuthClientLegacy from './client.legacy'
import getOAuthClient from './client'
import logger from '../../../lib/logger'
class OAuthCallbackError extends Error {
constructor (message) {
super(message)
this.name = 'OAuthCallbackError'
this.message = message
}
}
import { OAuthCallbackHandlerError } from '../../../lib/errors'

export default async function oAuthCallback (req) {
const { provider, csrfToken } = req.options
const client = oAuthClient(provider)
/**
* Handles exchange of the authorization code
* for OAuth tokens, fetches the profile data
* from the /userinfo endpoint
* @docs https://tools.ietf.org/html/rfc6749#section-4.1.3
* @docs https://www.oauth.com/oauth2-servers/signing-in-with-google/verifying-the-user-info/
*
* @param {import('../../index').NextApiRequestWithOptions} req
* @returns {Promise<import('./callback').HandleOAuthCallbackResultObject>}
*/
export default async function handleOAuthCallback (req) {
const { body, options: { provider, csrfToken, secret } } = req
try {
if (body?.error) throw body.error
if (provider.version?.startsWith('2.')) {
const client = getOAuthClient(provider)

if (provider.version?.startsWith('2.')) {
// The "user" object is specific to the Apple provider and is provided on first sign in
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"[email protected]"}
let { code, user, state } = req.query // eslint-disable-line camelcase
// For OAuth 2.0 flows, check state returned and matches expected value
// (a hash of the NextAuth.js CSRF token).
//
// Apple does not support state verification.
if (provider.id !== 'apple') {
const expectedState = createHash('sha256').update(csrfToken).digest('hex')
if (state !== expectedState) {
throw new OAuthCallbackError('Invalid state returned from OAuth provider')
}
}
const params = client.callbackParams(req)

if (req.method === 'POST') {
try {
const body = JSON.parse(JSON.stringify(req.body))
if (body.error) {
throw new Error(body.error)
}
/** @type {import("openid-client").OAuthCallbackChecks} */
const checks = {
response_type: 'code'
}

code = body.code
user = body.user != null ? JSON.parse(body.user) : null
} catch (error) {
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error, req.body, provider.id, code)
throw error
if (provider.verifications?.includes('pkce')) {
checks.code_verifier = secret
}
if (provider.verifications?.includes('state')) {
checks.state = createHash('sha256').update(csrfToken).digest('hex')
}
}

// REVIEW: Is this used by any of the providers?
// Pass authToken in header by default (unless 'useAuthTokenHeader: false' is set)
if (Object.prototype.hasOwnProperty.call(provider, 'useAuthTokenHeader')) {
client.useAuthorizationHeaderforGET(provider.useAuthTokenHeader)
} else {
client.useAuthorizationHeaderforGET(true)
}
const tokens = await client.oauthCallback(provider.callbackUrl, params, checks)
const profile = await client.userinfo(tokens.access_token)

try {
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(code, provider)
const tokens = { accessToken, refreshToken, idToken: results.id_token }
let profileData
if (provider.idToken) {
// If we don't have an ID Token most likely the user hit a cancel
// button when signing in (or the provider is misconfigured).
//
// Unfortunately, we can't tell which, so we can't treat it as an
// error, so instead we just returning nothing, which will cause the
// user to be redirected back to the sign in page.
if (!results?.id_token) {
throw new OAuthCallbackError()
}
// The "user" object is specific to the Apple provider and is provided on first sign in
// e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"[email protected]"}
const user = JSON.parse(body?.user ?? null) ?? req.query.user
if (user) {
profile.user = user
}

// Support services that use OpenID ID Tokens to encode profile data
profileData = decodeIdToken(results.id_token)
} else {
profileData = await client.get(provider, accessToken, results)
const result = {
profile: provider.profile(profile),
account: {
provider: provider.id,
type: provider.type,
// REVIEW: Why provider AND id?
id: provider.id,
...tokens
},
OAuthProfile: profile
}

return _getProfile({ profileData, provider, tokens, user })
} catch (error) {
logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, provider.id, code)
throw error
return result
}
}

try {
// Handle OAuth v1.x
const {
oauth_token: oauthToken, oauth_verifier: oauthVerifier
} = req.query
const { accessToken, refreshToken, results } = await client.getOAuthAccessToken(oauthToken, null, oauthVerifier)
const profileData = await client.get(
provider.profileUrl,
accessToken,
refreshToken
)

const tokens = {
accessToken, refreshToken, idToken: results.id_token
}
const client = getOAuthClientLegacy(provider)
const { oauth_token: oauthToken, oauth_verifier: oauthVerifier } = req.query
const { accessToken, refreshToken } = await client.getOAuthAccessToken(oauthToken, null, oauthVerifier)

return _getProfile({
profileData, tokens, provider
})
return client.getProfile({ tokens: { accessToken, refreshToken }, provider })
} catch (error) {
logger.error('OAUTH_V1_GET_ACCESS_TOKEN_ERROR', error)
throw error
}
}

/**
* //6/30/2020 @geraldnolan added userData parameter to attach additional data to the profileData object
* Returns profile, raw profile and auth provider details
*/
async function _getProfile ({
profileData, tokens: { accessToken, refreshToken, idToken }, provider, user
}) {
try {
// Convert profileData into an object if it's a string
if (typeof profileData === 'string' || profileData instanceof String) {
profileData = JSON.parse(profileData)
}

// If a user object is supplied (e.g. Apple provider) add it to the profile object
if (user != null) {
profileData.user = user
}

profileData.idToken = idToken

logger.debug('PROFILE_DATA', profileData)

const profile = await provider.profile(profileData)
// Return profile, raw profile and auth provider details
return {
profile: {
...profile,
email: profile.email?.toLowerCase() ?? null
},
account: {
provider: provider.id,
type: provider.type,
id: profile.id,
refreshToken,
accessToken,
accessTokenExpires: null
},
OAuthProfile: profileData
}
} catch (exception) {
// If we didn't get a response either there was a problem with the provider
// response *or* the user cancelled the action with the provider.
//
// Unfortuately, we can't tell which - at least not in a way that works for
// all providers, so we return an empty object; the user should then be
// redirected back to the sign up page. We log the error to help developers
// who might be trying to debug this when configuring a new provider.
logger.error('OAUTH_PARSE_PROFILE_ERROR', exception, profileData)
return {
profile: null,
account: null,
OAuthProfile: profileData
}
}
}

function decodeIdToken (idToken) {
if (!idToken) {
throw new OAuthCallbackError('Missing JWT ID Token')
logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error)
throw new OAuthCallbackHandlerError('OAuth callback handler failed')
}
return jwtDecode(idToken, { json: true })
}
Loading