mirror of
https://github.com/bitinflow/nuxt-oauth.git
synced 2026-03-13 13:45:59 +00:00
PKCE implementation
This commit is contained in:
@@ -6,8 +6,8 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 📦 Simple OAuth 2 Implicit Grant authentication
|
- 📦 Authorization Code Grant with PKCE (default)
|
||||||
- 📦 PKCE Support (planned)
|
- 📦 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
|
- 📦 Intended to be used with laravel-passport
|
||||||
- 📦 Single OAuth provider support (currently)
|
- 📦 Single OAuth provider support (currently)
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,7 @@ export default defineNuxtConfig({
|
|||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
||||||
oauth: {
|
oauth: {
|
||||||
redirect: {
|
clientId: '98e1cb74-125a-4d60-b686-02c2f0c87521',
|
||||||
login: '/login/', // sandbox appends / at the end of url
|
scope: ['user:read']
|
||||||
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']
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,10 +11,16 @@ export interface ModuleOptions {
|
|||||||
},
|
},
|
||||||
endpoints: {
|
endpoints: {
|
||||||
authorization: string,
|
authorization: string,
|
||||||
|
token: string,
|
||||||
userInfo: string,
|
userInfo: string,
|
||||||
logout: string | null
|
logout: string | null
|
||||||
},
|
},
|
||||||
|
refreshToken: {
|
||||||
|
maxAge: number,
|
||||||
|
}
|
||||||
clientId: string,
|
clientId: string,
|
||||||
|
responseType: 'token' | 'code',
|
||||||
|
prompt: '' | 'none' | 'login' | 'consent',
|
||||||
scope: string[]
|
scope: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,11 +33,17 @@ const defaults: ModuleOptions = {
|
|||||||
},
|
},
|
||||||
endpoints: {
|
endpoints: {
|
||||||
authorization: 'https://accounts.bitinflow.com/oauth/authorize',
|
authorization: 'https://accounts.bitinflow.com/oauth/authorize',
|
||||||
|
token: 'https://accounts.bitinflow.com/oauth/token',
|
||||||
userInfo: `https://accounts.bitinflow.com/api/v3/user`,
|
userInfo: `https://accounts.bitinflow.com/api/v3/user`,
|
||||||
logout: null,
|
logout: null,
|
||||||
},
|
},
|
||||||
|
refreshToken: {
|
||||||
|
maxAge: 60 * 60 * 24 * 30,
|
||||||
|
},
|
||||||
clientId: 'please-set-client-id',
|
clientId: 'please-set-client-id',
|
||||||
scope: ['user:read']
|
responseType: 'code',
|
||||||
|
prompt: '',
|
||||||
|
scope: []
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineNuxtModule<ModuleOptions>({
|
export default defineNuxtModule<ModuleOptions>({
|
||||||
@@ -40,7 +52,7 @@ export default defineNuxtModule<ModuleOptions>({
|
|||||||
configKey: 'oauth'
|
configKey: 'oauth'
|
||||||
},
|
},
|
||||||
defaults,
|
defaults,
|
||||||
setup (moduleOptions, nuxt) {
|
setup(moduleOptions, nuxt) {
|
||||||
const resolver = createResolver(import.meta.url)
|
const resolver = createResolver(import.meta.url)
|
||||||
|
|
||||||
const options = defu(moduleOptions, {
|
const options = defu(moduleOptions, {
|
||||||
@@ -48,7 +60,7 @@ export default defineNuxtModule<ModuleOptions>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Set up runtime configuration
|
// 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, {
|
nuxt.options.runtimeConfig.oauth = defu(nuxt.options.runtimeConfig.oauth, {
|
||||||
...options
|
...options
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {CookieRef, navigateTo, useCookie, useRuntimeConfig} from "#app";
|
import {CookieRef, navigateTo, useCookie, useRuntimeConfig} from "#app";
|
||||||
import {ModuleOptions} from "../../module";
|
import {ModuleOptions} from "../../module";
|
||||||
|
import {generateRandomString, getChallengeFromVerifier} from "../support";
|
||||||
|
|
||||||
declare interface ComposableOptions {
|
declare interface ComposableOptions {
|
||||||
fetchUserOnInitialization: boolean
|
fetchUserOnInitialization: boolean
|
||||||
@@ -7,6 +8,7 @@ declare interface ComposableOptions {
|
|||||||
|
|
||||||
let user: CookieRef<any>;
|
let user: CookieRef<any>;
|
||||||
let accessToken: CookieRef<any>;
|
let accessToken: CookieRef<any>;
|
||||||
|
let refreshToken: CookieRef<any>;
|
||||||
|
|
||||||
export default async (options: ComposableOptions = {
|
export default async (options: ComposableOptions = {
|
||||||
fetchUserOnInitialization: false
|
fetchUserOnInitialization: false
|
||||||
@@ -14,8 +16,9 @@ export default async (options: ComposableOptions = {
|
|||||||
const authConfig = useRuntimeConfig().public.oauth as ModuleOptions;
|
const authConfig = useRuntimeConfig().public.oauth as ModuleOptions;
|
||||||
if (user == null) user = useCookie('oauth_user');
|
if (user == null) user = useCookie('oauth_user');
|
||||||
if (accessToken == null) accessToken = useCookie('oauth_access_token');
|
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 {
|
try {
|
||||||
const response = await fetch(authConfig.endpoints.userInfo, {
|
const response = await fetch(authConfig.endpoints.userInfo, {
|
||||||
headers: {
|
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
|
// create oauth authorization url
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: authConfig.clientId,
|
client_id: authConfig.clientId,
|
||||||
redirect_uri: window.location.origin + authConfig.redirect.callback,
|
redirect_uri: window.location.origin + authConfig.redirect.callback,
|
||||||
response_type: 'token',
|
response_type: authConfig.responseType,
|
||||||
scope: authConfig.scope.join(' ')
|
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()}`
|
window.location.href = `${authConfig.endpoints.authorization}?${params.toString()}`
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,11 +77,15 @@ export default async (options: ComposableOptions = {
|
|||||||
return navigateTo(authConfig.redirect.logout)
|
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};
|
accessToken.value = {token, tokenType, expiresAt: Date.now() + expires * 1000};
|
||||||
await fetchUser()
|
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
|
// Initialize the user if the option is set to true
|
||||||
if (options.fetchUserOnInitialization) {
|
if (options.fetchUserOnInitialization) {
|
||||||
await fetchUser()
|
await fetchUser()
|
||||||
@@ -82,8 +102,10 @@ export default async (options: ComposableOptions = {
|
|||||||
signIn,
|
signIn,
|
||||||
signOut,
|
signOut,
|
||||||
setBearerToken,
|
setBearerToken,
|
||||||
|
setRefreshToken,
|
||||||
bearerToken,
|
bearerToken,
|
||||||
accessToken,
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
authConfig
|
authConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,78 @@
|
|||||||
import {addRouteMiddleware, defineNuxtPlugin, navigateTo} from '#app'
|
import {addRouteMiddleware, defineNuxtPlugin, navigateTo, useCookie} from '#app'
|
||||||
import useAuth from "./composables/useAuth"
|
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(() => {
|
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) => {
|
addRouteMiddleware('auth', async (to) => {
|
||||||
const {user, authConfig, setBearerToken} = await useAuth()
|
const {user, authConfig, setBearerToken, setRefreshToken} = await useAuth()
|
||||||
|
|
||||||
if (to.path === authConfig.redirect.callback) {
|
if (to.path === authConfig.redirect.callback) {
|
||||||
const queryParams = new URLSearchParams(to.query.toString());
|
const queryParams = new URLSearchParams(to.query.toString());
|
||||||
@@ -11,15 +80,12 @@ export default defineNuxtPlugin(() => {
|
|||||||
return navigateTo(authConfig.redirect.login)
|
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')) {
|
if (authConfig.responseType === 'code') {
|
||||||
const token = hashParams.get('access_token') as string;
|
return await resolveUsingCode(to, authConfig, setBearerToken, setRefreshToken)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
36
src/runtime/support.ts
Normal file
36
src/runtime/support.ts
Normal 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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user