From 06feac925b0f77c81c5b5fe37bc9bf51ecb5813a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Preu=C3=9F?= Date: Sat, 8 Apr 2023 15:42:50 +0200 Subject: [PATCH] PKCE implementation --- README.md | 4 +- playground/nuxt.config.ts | 15 +----- src/module.ts | 18 +++++-- src/runtime/composables/useAuth.ts | 32 +++++++++-- src/runtime/plugin.ts | 86 ++++++++++++++++++++++++++---- src/runtime/support.ts | 36 +++++++++++++ 6 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 src/runtime/support.ts diff --git a/README.md b/README.md index 2c67cbe..e4102a9 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ ## Features -- 📦 Simple OAuth 2 Implicit Grant authentication -- 📦 PKCE Support (planned) +- 📦 Authorization Code Grant with PKCE (default) +- 📦 Simple OAuth 2 Implicit Grant authentication ([not recommended](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics)) - 📦 Intended to be used with laravel-passport - 📦 Single OAuth provider support (currently) diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 0e5396a..cb73035 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -4,18 +4,7 @@ export default defineNuxtConfig({ ssr: false, oauth: { - redirect: { - login: '/login/', // sandbox appends / at the end of url - logout: '/', - callback: '/login/', // sandbox appends / at the end of url - home: '/home' - }, - endpoints: { - authorization: 'https://api.sandbox.own3d.pro/v1/oauth/authorization', - userInfo: `https://id.stream.tv/api/users/@me`, - logout: 'https://id.stream.tv/oauth/token' - }, - clientId: '90a951d1-ea50-4fda-8c4d-275b81f7d219', - scope: ['user:read', 'connections'] + clientId: '98e1cb74-125a-4d60-b686-02c2f0c87521', + scope: ['user:read'] }, }) diff --git a/src/module.ts b/src/module.ts index cd014ba..6729fe6 100644 --- a/src/module.ts +++ b/src/module.ts @@ -11,10 +11,16 @@ export interface ModuleOptions { }, endpoints: { authorization: string, + token: string, userInfo: string, logout: string | null }, + refreshToken: { + maxAge: number, + } clientId: string, + responseType: 'token' | 'code', + prompt: '' | 'none' | 'login' | 'consent', scope: string[] } @@ -27,11 +33,17 @@ const defaults: ModuleOptions = { }, endpoints: { authorization: 'https://accounts.bitinflow.com/oauth/authorize', + token: 'https://accounts.bitinflow.com/oauth/token', userInfo: `https://accounts.bitinflow.com/api/v3/user`, logout: null, }, + refreshToken: { + maxAge: 60 * 60 * 24 * 30, + }, clientId: 'please-set-client-id', - scope: ['user:read'] + responseType: 'code', + prompt: '', + scope: [] } export default defineNuxtModule({ @@ -40,7 +52,7 @@ export default defineNuxtModule({ configKey: 'oauth' }, defaults, - setup (moduleOptions, nuxt) { + setup(moduleOptions, nuxt) { const resolver = createResolver(import.meta.url) const options = defu(moduleOptions, { @@ -48,7 +60,7 @@ export default defineNuxtModule({ }) // Set up runtime configuration - nuxt.options.runtimeConfig = nuxt.options.runtimeConfig || { public: {} } + nuxt.options.runtimeConfig = nuxt.options.runtimeConfig || {public: {}} nuxt.options.runtimeConfig.oauth = defu(nuxt.options.runtimeConfig.oauth, { ...options }) diff --git a/src/runtime/composables/useAuth.ts b/src/runtime/composables/useAuth.ts index ccea36f..29206f6 100644 --- a/src/runtime/composables/useAuth.ts +++ b/src/runtime/composables/useAuth.ts @@ -1,5 +1,6 @@ import {CookieRef, navigateTo, useCookie, useRuntimeConfig} from "#app"; import {ModuleOptions} from "../../module"; +import {generateRandomString, getChallengeFromVerifier} from "../support"; declare interface ComposableOptions { fetchUserOnInitialization: boolean @@ -7,6 +8,7 @@ declare interface ComposableOptions { let user: CookieRef; let accessToken: CookieRef; +let refreshToken: CookieRef; export default async (options: ComposableOptions = { fetchUserOnInitialization: false @@ -14,8 +16,9 @@ export default async (options: ComposableOptions = { const authConfig = useRuntimeConfig().public.oauth as ModuleOptions; if (user == null) user = useCookie('oauth_user'); if (accessToken == null) accessToken = useCookie('oauth_access_token'); + if (refreshToken == null) refreshToken = useCookie('oauth_refresh_token'); - const fetchUser = async () => { + const fetchUser = async (): Promise => { try { const response = await fetch(authConfig.endpoints.userInfo, { headers: { @@ -32,15 +35,28 @@ export default async (options: ComposableOptions = { } } - const signIn = async () => { + const signIn = async (): Promise => { + const state = useCookie('oauth_state'); + state.value = generateRandomString(); + // create oauth authorization url const params = new URLSearchParams({ client_id: authConfig.clientId, redirect_uri: window.location.origin + authConfig.redirect.callback, - response_type: 'token', - scope: authConfig.scope.join(' ') + response_type: authConfig.responseType, + scope: authConfig.scope.join(' '), + state: state.value, + prompt: authConfig.prompt }) + if (authConfig.responseType === 'code') { + const codeVerifier = useCookie('oauth_code_verifier'); + codeVerifier.value = generateRandomString(); + + params.set('code_challenge', await getChallengeFromVerifier(codeVerifier.value)) + params.set('code_challenge_method', 'S256') + } + window.location.href = `${authConfig.endpoints.authorization}?${params.toString()}` }; @@ -61,11 +77,15 @@ export default async (options: ComposableOptions = { return navigateTo(authConfig.redirect.logout) } - const setBearerToken = async (token: string, tokenType: string, expires: number) => { + const setBearerToken = async (token: string, tokenType: string, expires: number): Promise => { accessToken.value = {token, tokenType, expiresAt: Date.now() + expires * 1000}; await fetchUser() } + const setRefreshToken = async (token: string, tokenType: string, expires: number): Promise => { + refreshToken.value = {token, tokenType, expiresAt: Date.now() + expires * 1000}; + } + // Initialize the user if the option is set to true if (options.fetchUserOnInitialization) { await fetchUser() @@ -82,8 +102,10 @@ export default async (options: ComposableOptions = { signIn, signOut, setBearerToken, + setRefreshToken, bearerToken, accessToken, + refreshToken, authConfig } } diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index fe4d23c..ce2236b 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -1,9 +1,78 @@ -import {addRouteMiddleware, defineNuxtPlugin, navigateTo} from '#app' +import {addRouteMiddleware, defineNuxtPlugin, navigateTo, useCookie} from '#app' import useAuth from "./composables/useAuth" +import {RouteLocationNormalized} from "vue-router"; +import {ModuleOptions} from "../module"; + +interface AccessToken { + access_token: string, + token_type: string, + expires_in: number, + refresh_token: string + scope: string +} export default defineNuxtPlugin(() => { + const resolveUsingToken = async ( + to: RouteLocationNormalized, + authConfig: ModuleOptions, + setBearerToken: (token: string, tokenType: string, expires: number) => Promise + ) => { + const hashParams = new URLSearchParams(to.hash.substring(1)) + + if (hashParams.has('access_token')) { + const token = hashParams.get('access_token') as string; + const tokenType = hashParams.get('token_type') as string; + const expires = hashParams.get('expires_in') as string; + + await setBearerToken(token, tokenType, parseInt(expires)); + return navigateTo(authConfig.redirect.home) + } + } + + const resolveUsingCode = async ( + to: RouteLocationNormalized, + authConfig: ModuleOptions, + setBearerToken: (token: string, tokenType: string, expires: number) => Promise, + setRefreshToken: (token: string, tokenType: string, expires: number) => Promise + ) => { + + if (to.query['code']) { + const code = to.query['code'] as string; + const stateFromRequest = to.query['state'] as string; + const stateFromCookie = useCookie('oauth_state'); + const codeVerifier = useCookie('oauth_code_verifier'); + + if (stateFromRequest !== stateFromCookie.value) { + console.warn('State mismatch', stateFromRequest, stateFromCookie.value) + return navigateTo(authConfig.redirect.login) + } + + const formData = new FormData(); + formData.append('grant_type', 'authorization_code') + formData.append('client_id', authConfig.clientId) + formData.append('redirect_uri', window.location.origin + authConfig.redirect.callback) + formData.append('code_verifier', codeVerifier.value) + formData.append('code', code) + + const response: Response = await fetch(authConfig.endpoints.token, { + method: 'POST', + body: formData, + }) + + if (!response.ok) { + console.warn('Failed to fetch token', response) + return navigateTo(authConfig.redirect.login) + } + + const data: AccessToken = await response.json(); + await setBearerToken(data.access_token, data.token_type, data.expires_in) + await setRefreshToken(data.refresh_token, data.token_type, authConfig.refreshToken.maxAge) + return navigateTo(authConfig.redirect.home) + } + } + addRouteMiddleware('auth', async (to) => { - const {user, authConfig, setBearerToken} = await useAuth() + const {user, authConfig, setBearerToken, setRefreshToken} = await useAuth() if (to.path === authConfig.redirect.callback) { const queryParams = new URLSearchParams(to.query.toString()); @@ -11,15 +80,12 @@ export default defineNuxtPlugin(() => { return navigateTo(authConfig.redirect.login) } - const hashParams = new URLSearchParams(to.hash.substring(1)) + if (authConfig.responseType === 'token') { + return await resolveUsingToken(to, authConfig, setBearerToken) + } - if (hashParams.has('access_token')) { - const token = hashParams.get('access_token') as string; - const tokenType = hashParams.get('token_type') as string; - const expires = hashParams.get('expires_in') as string; - - await setBearerToken(token, tokenType, parseInt(expires)); - return navigateTo(authConfig.redirect.home) + if (authConfig.responseType === 'code') { + return await resolveUsingCode(to, authConfig, setBearerToken, setRefreshToken) } return diff --git a/src/runtime/support.ts b/src/runtime/support.ts new file mode 100644 index 0000000..7f777f4 --- /dev/null +++ b/src/runtime/support.ts @@ -0,0 +1,36 @@ +/* + * Source: https://docs.cotter.app/sdk-reference/api-for-other-mobile-apps/api-for-mobile-apps + */ + +function dec2hex(dec: any) { + return ('0' + dec.toString(16)).substr(-2) +} + +export function generateRandomString() { + const array = new Uint32Array(56 / 2); + window.crypto.getRandomValues(array); + return Array.from(array, dec2hex).join(''); +} + +function sha256(plain: any) { + const encoder = new TextEncoder(); + const data = encoder.encode(plain); + return window.crypto.subtle.digest('SHA-256', data); +} + +function base64urlencode(a: any) { + let str = ""; + const bytes = new Uint8Array(a); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + str += String.fromCharCode(bytes[i]); + } + return btoa(str) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +export async function getChallengeFromVerifier(v: any) { + return base64urlencode(await sha256(v)); +}