-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
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: /tokens endpoint #951
Changes from all commits
e031591
7b6fd81
d155720
c8de34d
2ee460d
0327b90
0b73437
766874d
d242d72
6088a05
93f4dc0
06a160a
47b8788
1aea187
4635113
12d7856
8c56e13
3abb0c8
831c59d
dde908b
9dbd372
ddaa830
7a4534a
5fcf80c
3c3a4d2
7ce37c7
bd86e7c
19f2664
be159b1
f87e8b3
5811248
b98fb61
16849d1
4f89d74
8827950
d03504c
eb828d4
28e2afb
28ce71d
652ac7d
1a315fe
a490686
66ec439
aa4439e
15196ee
2eb17cb
1728f50
c31cbbc
ee398d1
abcf845
9b579b5
b9862b8
bf7efbc
82d16e6
c684336
7fa4275
ca06976
f2ad693
a8362ec
c8e76b4
91e26ca
e17acb6
4d89b27
0380edf
f1f1449
b5c4e91
b5384e7
20f40d0
f7ff4c9
7a1d712
3fcdd22
65504d6
fb8ec8a
44ffd55
173df76
e504044
416d92c
e7e8e0f
f05644d
2e4832c
354d6c3
1838e43
ad791ea
54a28b5
47621b5
72b6050
0c40529
ecbaa14
1a1a1f9
b1f6901
2f88880
d520687
d02c415
3bec8ea
de9538d
0987f72
b3c7617
47bcd1e
4f93e6a
536f0ad
d56fa6a
6c84325
47a5c9d
b61bfc2
af36bd5
cf2f899
1aeca00
e9fd979
393bd4a
f4d9e54
ba35ada
d2cd02a
6c5db3c
5dca70e
4f481ee
63c9d83
048c1f2
10c4d9d
4ab2cca
7dc147a
4a8da44
a2e68cd
f106a9e
8a1798f
da01aa6
909acab
690f81e
562bcf2
c387f32
afb5082
21f2231
4634374
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
export interface CookieOption { | ||
name?: string | ||
options?: { | ||
httpOnly?: boolean | ||
sameSite?: "lax" | "none" | "strict" | ||
path?: string | ||
secure?: boolean | ||
} | ||
} | ||
|
||
|
||
export type CookieType = "sessionToken" | "callbackUrl" | "csrfToken" | "accessToken" | "idToken" | ||
|
||
export interface AccessToken { | ||
accessToken: string | ||
refreshToken?: string | ||
/** Saved as ISO string */ | ||
accessTokenExpires?: Date | ||
} | ||
|
||
export type IdToken = string | ||
|
||
export interface CookieOptions extends Record<CookieType, CookieOption> { | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
export interface Account { | ||
accessToken?: string | ||
accessTokenExpires?: Date | null | ||
refreshToken?: string | ||
idToken?: string | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -281,3 +281,56 @@ export default async function callback (req, res) { | |
} | ||
return res.status(500).end(`Error: Callback for provider type ${provider.type} not supported`) | ||
} | ||
|
||
/** | ||
* A common usecase is to put the access_token into the session token. | ||
* While nothing wrong with it, the access_token quickly fills up the 4096 bytes | ||
* allowed by most browsers. We therefore put id_token, and access_token/refresh_token | ||
* in their own cookies. | ||
* @param {{ | ||
* cookies: import('../cookies').CookieOptions | ||
* res: import('next').NextApiResponse | ||
* maxAge: number | ||
* account: import('../lib/oauth/index').Account | ||
* secret: string | ||
* }} SaveOAuthTokensInCookieParams | ||
*/ | ||
function saveOAuthTokensInCookie ({ res, cookies, account, maxAge, secret }) { | ||
const expires = new Date() | ||
expires.setTime(expires.getTime() + (maxAge * 1000)) | ||
|
||
if (account.accessToken) { | ||
let accessTokenExpires = account.accessTokenExpires | ||
if (accessTokenExpires) { | ||
accessTokenExpires = new Date(accessTokenExpires) | ||
} else { | ||
accessTokenExpires = expires | ||
} | ||
// Save the access_token and refresh_token together | ||
const accessToken = { | ||
accessToken: account.accessToken, | ||
refreshToken: account.refreshToken | ||
} | ||
|
||
// TODO: Encrypt with secret | ||
const encryptedAccessToken = JSON.stringify(accessToken) | ||
|
||
cookie.set( | ||
res, | ||
cookies.accessToken.name, | ||
encryptedAccessToken, | ||
{ expires: accessTokenExpires.toISOString(), ...cookies.accessToken.options } | ||
) | ||
} | ||
|
||
if (account.idToken) { | ||
// TODO: Encrypt with secret | ||
const encryptedIdToken = account.idToken | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needs encryption |
||
cookie.set( | ||
res, | ||
cookies.idToken.name, | ||
encryptedIdToken, | ||
{ expires: expires, ...cookies.idToken.options } | ||
) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
|
||
// NOTE: fetch() is built in to Next.js 9.4 | ||
/* global fetch:false */ | ||
import cookie from '../lib/cookie' | ||
import logger from '../../lib/logger' | ||
|
||
/** | ||
* | ||
* @param {import('next').NextApiRequest} req | ||
* @param {import('next').NextApiResponse} res | ||
* @param {import('..').NextAuthOptions} options | ||
* @param {(value: any) => void} done | ||
*/ | ||
export default async function tokens (req, res, options, done) { | ||
const { query } = req | ||
const { | ||
nextauth, | ||
tokenType = nextauth[2], | ||
action = nextauth[3] | ||
} = query | ||
const providerName = options?.provider | ||
|
||
const { cookies } = options | ||
const useJwtSession = options.session.jwt | ||
const hasAccessToken = cookies.sessionToken.name in req.cookies | ||
const sessionToken = req.cookies[cookies.sessionToken.name] | ||
|
||
let response = {} | ||
res.setHeader('Content-Type', 'application/json') | ||
|
||
if (!hasAccessToken && !sessionToken) { | ||
res.json(response) | ||
return done() | ||
} | ||
|
||
if (req.method === 'GET') { | ||
if (useJwtSession) { | ||
const provider = options.providers[providerName] | ||
if (provider?.type !== 'oauth') { | ||
res.json(response) | ||
logger.error('INVALID_TOKEN_PROVIDER', 'Invalid ') | ||
return done() | ||
} | ||
// TODO: decrypt with options.secret | ||
/** @type {import('../cookies').AccessToken} */ | ||
let accessToken = req[cookies.accessToken.name] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Needs decryption |
||
|
||
if (new Date().toISOString() < accessToken.accessTokenExpires) { | ||
// The access token is still fresh, return it | ||
response = { | ||
token: accessToken.accessToken, | ||
expires: accessToken.accessTokenExpires | ||
} | ||
} else { | ||
// If the provider is OIDC compliant, we can try to refresh the token | ||
if (provider.oidc) { | ||
try { | ||
accessToken = await refreshAccessToken({ accessToken, provider }) | ||
response = accessToken | ||
} catch (error) { | ||
logger.error('REFRESH_TOKEN_ERROR', error) | ||
} | ||
} else { | ||
// REVIEW: redirect to signin(?) | ||
} | ||
} | ||
} else { | ||
const { adapter } = options | ||
try { | ||
const { getSession, updateSession, getAccounts, getAccount } = await adapter.getAdapter(options) | ||
const session = await getSession(sessionToken) | ||
if (session) { | ||
// Trigger update to session object to update session expiry | ||
await updateSession(session) | ||
|
||
if (!providerName) { | ||
response = await getAccounts(session.userId) | ||
} else { | ||
let expired | ||
// TODO: Determine if tokens have expired | ||
|
||
if (action === 'renew' || expired) { | ||
// TODO: Exchange refresh token for access token | ||
} | ||
|
||
const account = await getAccount(session.userId, providerName) | ||
if (account) { | ||
switch (tokenType) { | ||
case undefined: | ||
case 'access': | ||
response = { | ||
type: 'access', | ||
token: account.accessToken | ||
} | ||
break | ||
case 'refresh': | ||
response = { | ||
type: 'refresh', | ||
token: account.refreshToken | ||
} | ||
break | ||
default: | ||
res.status(404).end() | ||
return done() | ||
} | ||
} else { | ||
res.status(404).end() | ||
return done() | ||
} | ||
} | ||
} else if (sessionToken) { | ||
// If sessionToken was found set but it's not valid for a session then | ||
// remove the sessionToken cookie from browser. | ||
cookie.set(res, cookies.sessionToken.name, '', { ...cookies.sessionToken.options, maxAge: 0 }) | ||
} | ||
} catch (error) { | ||
logger.error('TOKEN_ERROR', error) | ||
} | ||
} | ||
} | ||
|
||
res.json(response) | ||
return done() | ||
} | ||
|
||
/** | ||
* @param {{ | ||
* token: import('../cookies').AccessToken, | ||
* provider: { | ||
* accessTokenUrl: string | ||
* clientId: string | ||
* clientSecret: string | ||
* } | ||
* }} params | ||
*/ | ||
async function refreshAccessToken ({ token, provider }) { | ||
const response = await fetch(provider.accessTokenUrl, { | ||
body: new URLSearchParams({ | ||
client_id: provider.clientId, | ||
client_secret: provider.clientSecret, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. pkce does not have a secret, may need some others params also |
||
grant_type: 'refresh_token', | ||
refresh_token: token.refreshToken | ||
}), | ||
headers: { | ||
'Content-Type': 'application/x-www-form-urlencoded' | ||
}, | ||
method: 'POST' | ||
}) | ||
|
||
const newTokens = await response.json() | ||
|
||
if (!response.ok) { | ||
throw new Error({ newTokens }) | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should also update the cookie with these new tokens |
||
return { | ||
...token, | ||
accessToken: newTokens.access_token, | ||
accessTokenExpires: new Date(newTokens.expires_in).toISOString(), | ||
// Fallback to the previous refresh_token, if it is not rotating/sliding | ||
refreshToken: newTokens.refresh_token || token.refreshToken | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Needs encryption