Skip to content
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

feat(provider): option to disable client-side redirects (credentials) #1219

Merged
merged 5 commits into from
Feb 1, 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
5 changes: 5 additions & 0 deletions components/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export default function Header () {
<a>API</a>
</Link>
</li>
<li className={styles.navItem}>
<Link href='/credentials'>
<a>Credentials</a>
</Link>
</li>
</ul>
</nav>
</header>
Expand Down
84 changes: 18 additions & 66 deletions pages/api/auth/[...nextauth].js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import NextAuth from "next-auth"
import Providers from "next-auth/providers"

export default NextAuth({
// https://next-auth.js.org/configuration/providers
providers: [
Providers.GitHub({
clientId: process.env.GITHUB_ID,
Expand All @@ -18,75 +17,28 @@ export default NextAuth({
clientId: process.env.TWITTER_ID,
clientSecret: process.env.TWITTER_SECRET,
}),
Providers.Credentials({
name: "Credentials",
credentials: {
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (credentials.password === "password") {
return {
id: 1,
name: 'Fill Murray',
email: 'bill@fillmurray.com',
image: "https://www.fillmurray.com/64/64"
}
}
return null
}
}),
],
// Database optional. MySQL, Maria DB, Postgres and MongoDB are supported.
// https://next-auth.js.org/configuration/databases
//
// Notes:
// * You must to install an appropriate node_module for your database
// * The Email provider requires a database (OAuth providers do not)

// The secret should be set to a reasonably long random string.
// It is used to sign cookies and to sign and encrypt JSON Web Tokens, unless
// a separate secret is defined explicitly for encrypting the JWT.

session: {
// Use JSON Web Tokens for session instead of database sessions.
// This option can be used with or without a database for users/accounts.
// Note: `jwt` is automatically set to `true` if no database is specified.
jwt: true,

// Seconds - How long until an idle session expires and is no longer valid.
// maxAge: 30 * 24 * 60 * 60, // 30 days

// Seconds - Throttle how frequently to write to database to extend a session.
// Use it to limit write operations. Set to 0 to always update the database.
// Note: This option is ignored if using JSON Web Tokens
// updateAge: 24 * 60 * 60, // 24 hours
},

// JSON Web tokens are only used for sessions if the `jwt: true` session
// option is set - or by default if no database is specified.
// https://next-auth.js.org/configuration/options#jwt

jwt: {
encryption: true,
secret: process.env.SECRET,
// A secret to use for key generation (you should set this explicitly)
// secret: 'INp8IvdIyeMcoGAgFGoA61DdBglwwSqnXJZkgz8PSnw',
// Set to true to use encryption (default: false)
// encryption: true,
// 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 }) => {},
},

// You can define custom pages to override the built-in pages.
// The routes shown here are the default URLs that will be used when a custom
// pages is not specified for that route.
// https://next-auth.js.org/configuration/pages
pages: {
// signIn: '/api/auth/signin', // Displays signin buttons
// signOut: '/api/auth/signout', // Displays form with sign out button
// error: '/api/auth/error', // Error code passed in query string as ?error=
// verifyRequest: '/api/auth/verify-request', // Used for check email page
// newUser: null // If set, new users will be directed here on first sign in
},

// Callbacks are asynchronous functions you can use to control what happens
// 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) }
},

// Events are useful for logging
// https://next-auth.js.org/configuration/events
events: {},

// Enable debug messages in the console if you are having problems
debug: false,
})
60 changes: 60 additions & 0 deletions pages/credentials.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as React from 'react'
import {signIn, signOut, useSession} from 'next-auth/client'
import Layout from 'components/layout'

export default function Page () {
const [response, setResponse] = React.useState(null)
const handleLogin = (options) => async () => {
if (options.redirect) {
return signIn("credentials", options)
}
const response = await signIn("credentials", options)
setResponse(response)
if (response.ok) {
window.alert("Manually refreshing to update session, if login was successful")
window.location.reload()
}
}

const handleLogout = (options) => async () => {
if (options.redirect) {
return signOut(options)
}
const response = await signOut(options)
setResponse(response)
if (response.ok) {
window.alert("Manually refreshing to update session, if logout was successful")
window.location.reload()
}
}

const [session] = useSession()

if (session) {
return (
<Layout>
<h1>Test different flows for Credentials logout</h1>
<span className="spacing">Default:</span>
<button onClick={handleLogout({redirect: true})}>Logout</button><br/>
<span className="spacing">No redirect:</span>
<button onClick={handleLogout({redirect: false})}>Logout</button><br/>
<p>Response:</p>
<pre style={{background: "#eee", padding: 16}}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}

return (
<Layout>
<h1>Test different flows for Credentials login</h1>
<span className="spacing">Default:</span>
<button onClick={handleLogin({redirect: true, password: "password"})}>Login</button><br/>
<span className="spacing">No redirect:</span>
<button onClick={handleLogin({redirect: false, password: "password"})}>Login</button><br/>
<span className="spacing">No redirect, wrong password:</span>
<button onClick={handleLogin({redirect: false, password: ""})}>Login</button>
<p>Response:</p>
<pre style={{background: "#eee", padding: 16}}>{JSON.stringify(response, null, 2)}</pre>
</Layout>
)
}
109 changes: 71 additions & 38 deletions src/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,62 +233,101 @@ const _useSessionHook = (session) => {
return [data, loading]
}

// Client side method
export const signIn = async (provider, args = {}, authorizationParams = {}) => {
/**
* Client-side method to initiate a signin flow
* or send the user to the signin page listing all possible providers.
* (Automatically adds the CSRF token to the request)
* @see https://next-auth.js.org/getting-started/client#signin
* @param {string} [provider]
* @param {SignInOptions} [options]
* @param {object} [authorizationParams]
* @return {Promise<SignInResponse | undefined>}
* @typedef {{callbackUrl?: string; redirect?: boolean}} SignInOptions
* @typedef {{error: string | null; status: number; ok: boolean}} SignInResponse
*/
export async function signIn (provider, options = {}, authorizationParams = {}) {
const {
callbackUrl = window.location,
redirect = true
} = options

const baseUrl = _apiBaseUrl()
const callbackUrl = args?.callbackUrl ?? window.location
const providers = await getProviders()

// Redirect to sign in page if no valid provider specified
if (!(provider in providers)) {
// If Provider not recognized, redirect to sign in page
window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`
} else {
const signInUrl = (providers[provider].type === 'credentials')
? `${baseUrl}/callback/${provider}`
: `${baseUrl}/signin/${provider}`

// If is any other provider type, POST to provider URL with CSRF Token,
// callback URL and any other parameters supplied.
const fetchOptions = {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: _encodedForm({
...args,
csrfToken: await getCsrfToken(),
callbackUrl: callbackUrl,
json: true
})
}
const _signInUrl = `${signInUrl}?${_encodedForm(authorizationParams)}`
const res = await fetch(_signInUrl, fetchOptions)
const data = await res.json()
return
}
const isCredentials = providers[provider].type === 'credentials'
const signInUrl = isCredentials
? `${baseUrl}/callback/${provider}`
: `${baseUrl}/signin/${provider}`

// If is any other provider type, POST to provider URL with CSRF Token,
// callback URL and any other parameters supplied.
const fetchOptions = {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
...options,
csrfToken: await getCsrfToken(),
callbackUrl,
json: true
})
}
const _signInUrl = `${signInUrl}?${new URLSearchParams(authorizationParams)}`
const res = await fetch(_signInUrl, fetchOptions)
const data = await res.json()
if (redirect || !isCredentials) {
window.location = data.url ?? callbackUrl
return
}
}

// Client side method
export const signOut = async (args = {}) => {
const callbackUrl = args.callbackUrl ?? window.location
const error = new URL(data.url).searchParams.get('error')
return {
error,
status: res.status,
ok: res.ok
}
}

/**
* Signs the user out, by removing the session cookie.
* (Automatically adds the CSRF token to the request)
* @param {SignOutOptions} [options]
* @returns {Promise<{url?: string} | undefined>}
* @typedef {{callbackUrl?: string; redirect?: boolean;}} SignOutOptions
*/
export async function signOut (options = {}) {
const {
callbackUrl = window.location,
redirect = true
} = options
const baseUrl = _apiBaseUrl()
const fetchOptions = {
method: 'post',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: _encodedForm({
body: new URLSearchParams({
csrfToken: await getCsrfToken(),
callbackUrl: callbackUrl,
callbackUrl,
json: true
})
}
const res = await fetch(`${baseUrl}/signout`, fetchOptions)
const data = await res.json()
_sendMessage({ event: 'session', data: { trigger: 'signout' } })
window.location = data.url ?? callbackUrl
if (redirect) {
window.location = data.url ?? callbackUrl
return
}

return data
}

// Provider to wrap the app in to make session data available globally
Expand Down Expand Up @@ -321,12 +360,6 @@ const _apiBaseUrl = () => {
}
}

const _encodedForm = (formData) => {
return Object.keys(formData).map((key) => {
return encodeURIComponent(key) + '=' + encodeURIComponent(formData[key])
}).join('&')
}

const _sendMessage = (message) => {
if (typeof localStorage !== 'undefined') {
const timestamp = Math.floor(new Date().getTime() / 1000)
Expand Down
8 changes: 4 additions & 4 deletions src/server/routes/callback.js
Original file line number Diff line number Diff line change
Expand Up @@ -217,12 +217,12 @@ export default async function callback (req, res) {
} else if (provider.type === 'credentials' && req.method === 'POST') {
if (!useJwtSession) {
logger.error('CALLBACK_CREDENTIALS_JWT_ERROR', 'Signin in with credentials is only supported if JSON Web Tokens are enabled')
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
return res.status(500).redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}

if (!provider.authorize) {
logger.error('CALLBACK_CREDENTIALS_HANDLER_ERROR', 'Must define an authorize() handler to use credentials authentication provider')
return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`)
return res.status(500).redirect(`${baseUrl}${basePath}/error?error=Configuration`)
}

const credentials = req.body
Expand All @@ -231,7 +231,7 @@ export default async function callback (req, res) {
try {
userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials)
if (!userObjectReturnedFromAuthorizeHandler) {
return res.redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
return res.status(401).redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`)
}
} catch (error) {
if (error instanceof Error) {
Expand All @@ -246,7 +246,7 @@ export default async function callback (req, res) {
try {
const signInCallbackResponse = await callbacks.signIn(user, account, credentials)
if (signInCallbackResponse === false) {
return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
return res.status(403).redirect(`${baseUrl}${basePath}/error?error=AccessDenied`)
}
} catch (error) {
if (error instanceof Error) {
Expand Down