PKCE implementation

This commit is contained in:
René Preuß
2023-04-08 15:42:50 +02:00
parent f27a14c860
commit 06feac925b
6 changed files with 158 additions and 33 deletions

View File

@@ -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)

View File

@@ -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']
},
})

View File

@@ -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<ModuleOptions>({
@@ -40,7 +52,7 @@ export default defineNuxtModule<ModuleOptions>({
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<ModuleOptions>({
})
// 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
})

View File

@@ -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<any>;
let accessToken: CookieRef<any>;
let refreshToken: CookieRef<any>;
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<void> => {
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<void> => {
const state = useCookie<string>('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<string>('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<void> => {
accessToken.value = {token, tokenType, expiresAt: Date.now() + expires * 1000};
await fetchUser()
}
const setRefreshToken = async (token: string, tokenType: string, expires: number): Promise<void> => {
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
}
}

View File

@@ -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<void>
) => {
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<void>,
setRefreshToken: (token: string, tokenType: string, expires: number) => Promise<void>
) => {
if (to.query['code']) {
const code = to.query['code'] as string;
const stateFromRequest = to.query['state'] as string;
const stateFromCookie = useCookie<string>('oauth_state');
const codeVerifier = useCookie<string>('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

36
src/runtime/support.ts Normal file
View File

@@ -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));
}