diff --git a/composables/auth.js b/composables/auth.js index e58c44f6b..29080edf8 100644 --- a/composables/auth.js +++ b/composables/auth.js @@ -92,9 +92,14 @@ export const initAuth = async (oldToken = null) => { return auth } -export const getAuthUrl = (provider) => { +export const getAuthUrl = (provider, redirect = '') => { const config = useRuntimeConfig() const route = useRoute() - return `${config.public.apiBaseUrl}auth/init?url=${config.public.siteUrl}${route.path}&provider=${provider}` + if (redirect === '') { + redirect = route.path + } + const fullURL = `${config.public.siteUrl}${redirect}` + + return `${config.public.apiBaseUrl}auth/init?url=${fullURL}&provider=${provider}` } diff --git a/composables/fetch.js b/composables/fetch.js index b296e529a..be6920830 100644 --- a/composables/fetch.js +++ b/composables/fetch.js @@ -1,6 +1,6 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => { const config = useRuntimeConfig() - const base = process.server ? config.apiBaseUrl : config.public.apiBaseUrl + let base = process.server ? config.apiBaseUrl : config.public.apiBaseUrl if (!options.headers) { options.headers = {} @@ -16,5 +16,21 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => { options.headers.Authorization = auth.value.token } + if (options.apiVersion || options.internal) { + // Base may end in /vD/ or /vD. We would need to replace the digit with the new version number + // and keep the trailing slash if it exists + const baseVersion = base.match(/\/v\d\//) + + const replaceStr = options.internal ? `/_internal/` : `/v${options.apiVersion}/` + + if (baseVersion) { + base = base.replace(baseVersion[0], replaceStr) + } else { + base = base.replace(/\/v\d$/, replaceStr) + } + + delete options.apiVersion + } + return await $fetch(`${base}${url}`, options) } diff --git a/middleware/auth.js b/middleware/auth.js index 34061c991..870155375 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -1,7 +1,35 @@ +const whitelistedParams = ['flow', 'error'] + export default defineNuxtRouteMiddleware(async (_to, from) => { + const config = useRuntimeConfig() const auth = await useAuth() if (!auth.value.user) { - return navigateTo(`/auth/sign-in?redirect=${encodeURIComponent(from.fullPath)}`) + const fullPath = from.fullPath + + const url = new URL(fullPath, config.public.apiBaseUrl) + + const extractedParams = whitelistedParams.reduce((acc, param) => { + if (url.searchParams.has(param)) { + acc[param] = url.searchParams.get(param) + url.searchParams.delete(param) + } + return acc + }, {}) + + const redirectPath = encodeURIComponent(url.pathname + url.search) + + return await navigateTo( + { + path: '/auth/sign-in', + query: { + redirect: redirectPath, + ...extractedParams, + }, + }, + { + replace: true, + } + ) } }) diff --git a/pages/auth/authorize.vue b/pages/auth/authorize.vue new file mode 100644 index 000000000..42325f351 --- /dev/null +++ b/pages/auth/authorize.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/pages/auth/sign-in.vue b/pages/auth/sign-in.vue index 5b7cea65c..064768fb6 100644 --- a/pages/auth/sign-in.vue +++ b/pages/auth/sign-in.vue @@ -22,27 +22,27 @@

Sign in with

- + Discord - + GitHub - + Microsoft - + Google - + Steam - + GitLab @@ -111,6 +111,8 @@ useHead({ const auth = await useAuth() const route = useRoute() +const redirectTarget = route.query.redirect || '' + if (route.fullPath.includes('new_account=true')) { await navigateTo( `/auth/welcome?authToken=${route.query.code}${ @@ -122,7 +124,7 @@ if (route.fullPath.includes('new_account=true')) { } if (auth.value.user) { - await navigateTo('/dashboard') + await finishSignIn() } const turnstile = ref() @@ -190,6 +192,7 @@ async function begin2FASignIn() { } stopLoading() } + async function finishSignIn(token) { if (token) { await useAuth(token) @@ -197,7 +200,10 @@ async function finishSignIn(token) { } if (route.query.redirect) { - await navigateTo(route.query.redirect) + const redirect = decodeURIComponent(route.query.redirect) + await navigateTo(redirect, { + replace: true, + }) } else { await navigateTo('/dashboard') } diff --git a/pages/auth/sign-up.vue b/pages/auth/sign-up.vue index 32574b43e..44d644744 100644 --- a/pages/auth/sign-up.vue +++ b/pages/auth/sign-up.vue @@ -3,27 +3,27 @@

Sign up with

- + Discord - + GitHub - + Microsoft - + Google - + Steam - + GitLab @@ -129,6 +129,8 @@ useHead({ const auth = await useAuth() const route = useRoute() +const redirectTarget = route.query.redirect + if (route.fullPath.includes('new_account=true')) { await navigateTo( `/auth/welcome?authToken=${route.query.code}${ diff --git a/pages/settings.vue b/pages/settings.vue index 71c70cf39..294c11acb 100644 --- a/pages/settings.vue +++ b/pages/settings.vue @@ -15,8 +15,8 @@ - - + + @@ -25,6 +25,15 @@ + @@ -34,6 +43,7 @@ + diff --git a/pages/settings/authorizations.vue b/pages/settings/authorizations.vue new file mode 100644 index 000000000..c1c76e7a8 --- /dev/null +++ b/pages/settings/authorizations.vue @@ -0,0 +1,245 @@ + + + + diff --git a/utils/auth/scopes.ts b/utils/auth/scopes.ts new file mode 100644 index 000000000..d0f8887fb --- /dev/null +++ b/utils/auth/scopes.ts @@ -0,0 +1,157 @@ +export const Scopes = { + USER_READ_EMAIL: BigInt(1) << BigInt(0), + USER_READ: BigInt(1) << BigInt(1), + USER_WRITE: BigInt(1) << BigInt(2), + USER_DELETE: BigInt(1) << BigInt(3), + USER_AUTH_WRITE: BigInt(1) << BigInt(4), + NOTIFICATION_READ: BigInt(1) << BigInt(5), + NOTIFICATION_WRITE: BigInt(1) << BigInt(6), + PAYOUTS_READ: BigInt(1) << BigInt(7), + PAYOUTS_WRITE: BigInt(1) << BigInt(8), + ANALYTICS: BigInt(1) << BigInt(9), + PROJECT_CREATE: BigInt(1) << BigInt(10), + PROJECT_READ: BigInt(1) << BigInt(11), + PROJECT_WRITE: BigInt(1) << BigInt(12), + PROJECT_DELETE: BigInt(1) << BigInt(13), + VERSION_CREATE: BigInt(1) << BigInt(14), + VERSION_READ: BigInt(1) << BigInt(15), + VERSION_WRITE: BigInt(1) << BigInt(16), + VERSION_DELETE: BigInt(1) << BigInt(17), + REPORT_CREATE: BigInt(1) << BigInt(18), + REPORT_READ: BigInt(1) << BigInt(19), + REPORT_WRITE: BigInt(1) << BigInt(20), + REPORT_DELETE: BigInt(1) << BigInt(21), + THREAD_READ: BigInt(1) << BigInt(22), + THREAD_WRITE: BigInt(1) << BigInt(23), + PAT_CREATE: BigInt(1) << BigInt(24), + PAT_READ: BigInt(1) << BigInt(25), + PAT_WRITE: BigInt(1) << BigInt(26), + PAT_DELETE: BigInt(1) << BigInt(27), + SESSION_READ: BigInt(1) << BigInt(28), + SESSION_DELETE: BigInt(1) << BigInt(29), + PERFORM_ANALYTICS: BigInt(1) << BigInt(30), + COLLECTION_CREATE: BigInt(1) << BigInt(31), + COLLECTION_READ: BigInt(1) << BigInt(32), + COLLECTION_WRITE: BigInt(1) << BigInt(33), + COLLECTION_DELETE: BigInt(1) << BigInt(34), + ORGANIZATION_CREATE: BigInt(1) << BigInt(35), + ORGANIZATION_READ: BigInt(1) << BigInt(36), + ORGANIZATION_WRITE: BigInt(1) << BigInt(37), + ORGANIZATION_DELETE: BigInt(1) << BigInt(38), + SESSION_ACCESS: BigInt(1) << BigInt(39), +} + +export const restrictedScopes = [ + Scopes.PAT_READ, + Scopes.PAT_CREATE, + Scopes.PAT_WRITE, + Scopes.PAT_DELETE, + Scopes.SESSION_READ, + Scopes.SESSION_DELETE, + Scopes.SESSION_ACCESS, + Scopes.USER_AUTH_WRITE, + Scopes.USER_DELETE, + Scopes.PERFORM_ANALYTICS, +] + +export const scopeList = Object.entries(Scopes) + .filter(([_, value]) => !restrictedScopes.includes(value)) + .map(([key, _]) => key) + +export const encodeScopes = (scopes: string[]) => { + let scopeFlag = BigInt(0) + + // We iterate over the provided scopes + for (const scope of scopes) { + // We iterate over the entries of the Scopes object + for (const [scopeName, scopeFlagValue] of Object.entries(Scopes)) { + // If the scope name is the same as the provided scope, add the scope flag to the scopeFlag variable + if (scopeName === scope) { + scopeFlag = scopeFlag | scopeFlagValue + } + } + } + + return scopeFlag +} + +export const decodeScopes = (scopes: bigint | number) => { + if (typeof scopes === 'number') { + scopes = BigInt(scopes) + } + + const authorizedScopes = [] + + // We iterate over the entries of the Scopes object + for (const [scopeName, scopeFlag] of Object.entries(Scopes)) { + // If the scope flag is present in the provided number, add the scope name to the list + if ((scopes & scopeFlag) === scopeFlag) { + authorizedScopes.push(scopeName) + } + } + + return authorizedScopes +} + +export const hasScope = (scopes: bigint, scope: string) => { + const authorizedScopes = decodeScopes(scopes) + return authorizedScopes.includes(scope) +} + +export const toggleScope = (scopes: bigint, scope: string) => { + const authorizedScopes = decodeScopes(scopes) + if (authorizedScopes.includes(scope)) { + return encodeScopes(authorizedScopes.filter((authorizedScope) => authorizedScope !== scope)) + } else { + return encodeScopes([...authorizedScopes, scope]) + } +} + +export const getScopeDefinitions = (scopes: bigint) => { + return decodeScopes(scopes) + .filter((scope) => Object.keys(ScopeDescriptions).includes(scope)) + .map((scope) => (ScopeDescriptions as Record)[scope]) +} + +export const ScopeDescriptions = { + USER_READ_EMAIL: 'Read your email', + USER_READ: 'Access your public profile information', + USER_WRITE: 'Write to your profile', + USER_DELETE: 'Delete your account', + USER_AUTH_WRITE: 'Modify your authentication data', + NOTIFICATION_READ: 'Read your notifications', + NOTIFICATION_WRITE: 'Delete/View your notifications', + PAYOUTS_READ: 'Read your payouts data', + PAYOUTS_WRITE: 'Withdraw money', + ANALYTICS: 'Access your analytics data', + PROJECT_CREATE: 'Create new projects', + PROJECT_READ: 'Read all your projects', + PROJECT_WRITE: 'Write to project data', + PROJECT_DELETE: 'Delete your projects', + VERSION_CREATE: 'Create new versions', + VERSION_READ: 'Read all versions', + VERSION_WRITE: 'Write to version data', + VERSION_DELETE: 'Delete a version', + REPORT_CREATE: 'Create reports', + REPORT_READ: 'Read reports', + REPORT_WRITE: 'Edit reports', + REPORT_DELETE: 'Delete reports', + THREAD_READ: 'Read threads', + THREAD_WRITE: 'Write to threads', + PAT_CREATE: 'Create personal API tokens', + PAT_READ: 'View created API tokens', + PAT_WRITE: 'Edit personal API tokens', + PAT_DELETE: 'Delete your personal API tokens', + SESSION_READ: 'Read active sessions', + SESSION_DELETE: 'Delete sessions', + PERFORM_ANALYTICS: 'Perform analytics actions', + COLLECTION_CREATE: 'Create collections', + COLLECTION_READ: 'Read collections', + COLLECTION_WRITE: 'Write to collections', + COLLECTION_DELETE: 'Delete collections', + ORGANIZATION_CREATE: 'Create organizations', + ORGANIZATION_READ: 'Read organizations', + ORGANIZATION_WRITE: 'Write to organizations', + ORGANIZATION_DELETE: 'Delete organizations', + SESSION_ACCESS: 'Access modrinth-issued sessions', +}