Skip to content

feat(provider): reduce user facing API #1023

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jan 5, 2021
Merged
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
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
Loading