Skip to content

Commit

Permalink
feat(provider): reduce user facing API (#1023)
Browse files Browse the repository at this point in the history
Co-authored-by: Balazs Orban <balazs@nhi.no>
  • Loading branch information
balazsorban44 and Balazs Orban committed Feb 1, 2021
1 parent ecddaf6 commit 76b9832
Show file tree
Hide file tree
Showing 23 changed files with 171 additions and 176 deletions.
8 changes: 2 additions & 6 deletions src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ const _useSessionHook = (session) => {
}

// Client side method
const signIn = async (provider, args = {}, authParams = {}) => {
const signIn = async (provider, args = {}) => {
const baseUrl = _apiBaseUrl()
const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location
const providers = await getProviders()
Expand All @@ -233,14 +233,10 @@ const signIn = async (provider, args = {}, authParams = {}) => {
// If Provider not recognized, redirect to sign in page
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
} else {
let signInUrl = (providers[provider].type === 'credentials')
const signInUrl = (providers[provider].type === 'credentials')
? `${baseUrl}/callback/${provider}`
: `${baseUrl}/signin/${provider}`

if (authParams) {
signInUrl += `?${new URLSearchParams(authParams).toString()}`
}

// If is any other provider type, POST to provider URL with CSRF Token,
// callback URL and any other parameters supplied.
const fetchOptions = {
Expand Down
23 changes: 0 additions & 23 deletions src/providers/apple.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import jwt from 'jsonwebtoken'

export default (options) => {
return {
id: 'apple',
Expand All @@ -12,7 +10,6 @@ export default (options) => {
authorizationUrl: 'https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post',
profileUrl: null,
idToken: true,
state: false, // Apple doesn't support state verfication
profile: (profile) => {
// The name of the user will only return on first login
return {
Expand All @@ -23,30 +20,10 @@ export default (options) => {
},
clientId: null,
clientSecret: {
appleId: null,
teamId: null,
privateKey: null,
keyId: null
},
clientSecretCallback: async ({ appleId, keyId, teamId, privateKey }) => {
const response = jwt.sign(
{
iss: teamId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
aud: 'https://appleid.apple.com',
sub: appleId
},
// Automatically convert \\n into \n if found in private key. If the key
// is passed in an environment variable \n can get escaped as \\n
privateKey.replace(/\\n/g, '\n'),
{
algorithm: 'ES256',
keyid: keyId
}
)
return Promise.resolve(response)
},
...options
}
}
20 changes: 3 additions & 17 deletions src/providers/bungie.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,6 @@ export default (options) => {
requestTokenUrl: 'https://www.bungie.net/platform/app/oauth/token/',
authorizationUrl: 'https://www.bungie.net/en/OAuth/Authorize?response_type=code',
profileUrl: 'https://www.bungie.net/platform/User/GetBungieAccount/{membershipId}/254/',
prepareProfileRequest: ({ provider, url, headers, results }) => {
if (!results.membership_id) {
// internal error
// @TODO: handle better
throw new Error('Expected membership_id to be passed.')
}

if (!provider.apiKey) {
throw new Error('The Bungie provider requires the apiKey option to be present.')
}

headers['X-API-Key'] = provider.apiKey
url = url.replace('{membershipId}', results.membership_id)

return url
},
profile: (profile) => {
const { bungieNetUser: user } = profile.Response

Expand All @@ -36,7 +20,9 @@ export default (options) => {
email: null
}
},
apiKey: null,
headers: {
'X-API-Key': null
},
clientId: null,
clientSecret: null,
...options
Expand Down
1 change: 0 additions & 1 deletion src/providers/identity-server4.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ export default (options) => {
profile: (profile) => {
return { ...profile, id: profile.sub }
},
setGetAccessTokenAuthHeader: false,
...options
}
}
1 change: 0 additions & 1 deletion src/providers/okta.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export default (options) => {
profile: (profile) => {
return { ...profile, id: profile.sub }
},
setGetAccessTokenAuthHeader: false,
...options
}
}
3 changes: 1 addition & 2 deletions src/providers/slack.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ export default (options) => {
scope: [],
params: { grant_type: 'authorization_code' },
accessTokenUrl: 'https://slack.com/api/oauth.v2.access',
accessTokenGetter: (json) => json.authed_user.access_token,
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
additionalAuthorizeParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
authorizationParams: { user_scope: 'identity.basic,identity.email,identity.avatar' },
profileUrl: 'https://slack.com/api/users.identity',
profile: (profile) => {
const { user } = profile
Expand Down
6 changes: 2 additions & 4 deletions src/server/lib/oauth/callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { createHash } from 'crypto'
import { decode as jwtDecode } from 'jsonwebtoken'
import oAuthClient from './client'
import logger from '../../../lib/logger'

class OAuthCallbackError extends Error {
constructor (message) {
super(message)
Expand All @@ -22,9 +21,8 @@ export default async function oAuthCallback (req, csrfToken) {
// For OAuth 2.0 flows, check state returned and matches expected value
// (a hash of the NextAuth.js CSRF token).
//
// This check can be disabled for providers that do not support it by
// setting `state: false` as a option on the provider (defaults to true).
if (!Object.prototype.hasOwnProperty.call(provider, 'state') || provider.state === true) {
// 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')
Expand Down
73 changes: 52 additions & 21 deletions src/server/lib/oauth/client.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { OAuth, OAuth2 } from 'oauth'
import querystring from 'querystring'
import logger from '../../../lib/logger'
import { sign as jwtSign } from 'jsonwebtoken'

/**
* @TODO Refactor to remove dependancy on 'oauth' package
Expand Down Expand Up @@ -88,23 +89,33 @@ export default function oAuthClient (provider) {
*/
async function getOAuth2AccessToken (code, provider) {
const url = provider.accessTokenUrl
const setGetAccessTokenAuthHeader = (provider.setGetAccessTokenAuthHeader !== null) ? provider.setGetAccessTokenAuthHeader : true
const params = { ...provider.params } || {}
const headers = { ...provider.headers } || {}
const params = { ...provider.params }
const headers = { ...provider.headers }
const codeParam = (params.grant_type === 'refresh_token') ? 'refresh_token' : 'code'

if (!params[codeParam]) { params[codeParam] = code }

if (!params.client_id) { params.client_id = provider.clientId }

if (!params.client_secret) {
// For some providers it useful to be able to generate the secret on the fly
// e.g. For Sign in With Apple a JWT token using the properties in clientSecret
if (provider.clientSecretCallback) {
params.client_secret = await provider.clientSecretCallback(provider.clientSecret)
} else {
params.client_secret = provider.clientSecret
}
// For Apple the client secret must be generated on-the-fly.
// Using the properties in clientSecret to create a JWT.
if (provider.id === 'apple' && typeof provider.clientSecret === 'object') {
const { keyId, teamId, privateKey } = provider.clientSecret
const clientSecret = jwtSign({
iss: teamId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (86400 * 180), // 6 months
aud: 'https://appleid.apple.com',
sub: provider.clientId
},
// Automatically convert \\n into \n if found in private key. If the key
// is passed in an environment variable \n can get escaped as \\n
privateKey.replace(/\\n/g, '\n'),
{ algorithm: 'ES256', keyid: keyId }
)
params.client_secret = clientSecret
} else {
params.client_secret = provider.clientSecret
}

if (!params.redirect_uri) { params.redirect_uri = provider.callbackUrl }
Expand All @@ -116,9 +127,9 @@ async function getOAuth2AccessToken (code, provider) {
if (provider.id === 'reddit') {
headers.Authorization = 'Basic ' + Buffer.from((provider.clientId + ':' + provider.clientSecret)).toString('base64')
}
// Okta errors when this is set. Maybe there are other Providers that also wont like this.
if (setGetAccessTokenAuthHeader) {
if (!headers.Authorization) { headers.Authorization = `Bearer ${code}` }

if ((provider.id === 'okta' || provider.id === 'identity-server4') && !headers.Authorization) {
headers.Authorization = `Bearer ${code}`
}

const postData = querystring.stringify(params)
Expand Down Expand Up @@ -147,7 +158,12 @@ async function getOAuth2AccessToken (code, provider) {
// Clients of these services suffer a minor performance cost.
results = querystring.parse(data)
}
const accessToken = provider.accessTokenGetter ? provider.accessTokenGetter(results) : results.access_token
let accessToken
if (provider.id === 'spotify') {
accessToken = results.authed_user.access_token
} else {
accessToken = results.access_token
}
const refreshToken = results.refresh_token
resolve({ accessToken, refreshToken, results })
}
Expand All @@ -163,7 +179,7 @@ async function getOAuth2AccessToken (code, provider) {
*/
async function getOAuth2 (provider, accessToken, results) {
let url = provider.profileUrl
const headers = provider.headers || {}
const headers = { ...provider.headers }

if (this._useAuthorizationHeaderForGET) {
headers.Authorization = this.buildAuthHeader(accessToken)
Expand All @@ -176,14 +192,14 @@ async function getOAuth2 (provider, accessToken, results) {
}

// This line is required for Twitch
headers['Client-ID'] = provider.clientId
if (provider.id === 'twitch') {
headers['Client-ID'] = provider.clientId
}
accessToken = null
}

// Bungie
const prepareRequest = provider.prepareProfileRequest
if (prepareRequest) {
url = prepareRequest({ provider, url, headers, results }) || url
if (provider.id === 'bungie') {
url = prepareProfileUrl({ provider, url, results })
}

return new Promise((resolve, reject) => {
Expand All @@ -195,3 +211,18 @@ async function getOAuth2 (provider, accessToken, results) {
})
})
}

/** Bungie needs special handling */
function prepareProfileUrl ({ provider, url, results }) {
if (!results.membership_id) {
// internal error
// @TODO: handle better
throw new Error('Expected membership_id to be passed.')
}

if (!provider.headers?.['X-API-Key']) {
throw new Error('The Bungie provider requires the X-API-Key option to be present in "headers".')
}

return url.replace('{membershipId}', results.membership_id)
}
5 changes: 2 additions & 3 deletions src/server/lib/signin/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,17 @@ import oAuthClient from '../oauth/client'
import { createHash } from 'crypto'
import logger from '../../../lib/logger'

export default async function oauth (provider, csrfToken, authParams) {
export default async function oauth (provider, csrfToken) {
const { callbackUrl } = provider
const client = oAuthClient(provider)
if (provider.version?.startsWith('2.')) {
// Handle OAuth v2.x
let url = client.getAuthorizeUrl({
...authParams,
redirect_uri: callbackUrl,
scope: provider.scope,
// A hash of the NextAuth.js CSRF token is used as the state
state: createHash('sha256').update(csrfToken).digest('hex'),
...provider.additionalAuthorizeParams
...provider.authorizationParams
})

// If the authorizationUrl specified in the config has query parameters on it
Expand Down
5 changes: 1 addition & 4 deletions src/server/routes/signin.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,8 @@ export default async function signin (req, res) {
}

if (type === 'oauth' && req.method === 'POST') {
const authParams = { ...req.query }
delete authParams.nextauth // This is probably not intended to be sent to the provider, remove

try {
const oAuthSigninUrl = await oAuthSignin(provider, csrfToken, authParams)
const oAuthSigninUrl = await oAuthSignin(provider, csrfToken)
return res.redirect(oAuthSigninUrl)
} catch (error) {
logger.error('SIGNIN_OAUTH_ERROR', error)
Expand Down
12 changes: 6 additions & 6 deletions test/docker/app/pages/api/auth/[...nextauth].js
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@ const options = {

// You can define your own encode/decode functions for signing and encryption
// if you want to override the default behaviour.
// encode: async ({ secret, token, maxAge }) => {},
// decode: async ({ secret, token, maxAge }) => {},
// async encode({ secret, token, maxAge }) {},
// async decode({ secret, token, maxAge }) {},
},

// You can define custom pages to override the built-in pages.
Expand All @@ -101,10 +101,10 @@ const options = {
// when an action is performed.
// https://next-auth.js.org/configuration/callbacks
callbacks: {
// signIn: async (user, account, profile) => { return Promise.resolve(true) },
// redirect: async (url, baseUrl) => { return Promise.resolve(baseUrl) },
// session: async (session, user) => { return Promise.resolve(session) },
// jwt: async (token, user, account, profile, isNewUser) => { return Promise.resolve(token) }
// async signIn(user, account, profile) { return Promise.resolve(true) },
// async redirect(url, baseUrl) { return Promise.resolve(baseUrl) },
// async session(session, user) { return Promise.resolve(session) },
// async jwt(token, user, account, profile, isNewUser) { return Promise.resolve(token) }
},

// Events are useful for logging
Expand Down

0 comments on commit 76b9832

Please sign in to comment.