Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 29 additions & 16 deletions apps/frontend/src/components/ui/auth/SignIn.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@
type="colored"
color="brand"
class="!w-full !justify-center"
:href="getAuthUrl(focusedOauthProvider.id, redirectTarget)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl(focusedOauthProvider.id)"
:aria-label="
formatMessage(messages.continueWithProvider, {
provider: focusedOauthProvider.name,
Expand Down Expand Up @@ -144,7 +145,8 @@
:class="{
'!border !border-[var(--color-green)]': lastSignInOAuthProvider === provider.id,
}"
:href="getAuthUrl(provider.id, redirectTarget, requestsAppSession)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl(provider.id, requestsAppSession)"
:aria-label="
formatMessage(messages.continueWithProvider, { provider: provider.name })
"
Expand Down Expand Up @@ -274,6 +276,7 @@ import {
isLauncherProtocolV2,
LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY,
} from '@/composables/launcher-auth.ts'
import { useOAuthCodeChallenge } from '@/composables/oauth-pkce.ts'
import { type OAuthProviderId, oauthProviders } from '@/composables/oauth-providers.ts'
import { useSignIn } from '@/composables/sign-in.ts'

Expand Down Expand Up @@ -349,22 +352,32 @@ function openSignUp() {
})
}

const oauthCodeChallenge = useOAuthCodeChallenge()

function providerAuthUrl(provider: string, includeAppSession = false) {
const challenge = oauthCodeChallenge.value
if (!challenge) {
return undefined
}
return getAuthUrl(provider, redirectTarget, includeAppSession, challenge)
}

const onOAuthProviderClick = (provider: OAuthProviderId, event: MouseEvent) => {
beginProviderHandoff(
provider,
event,
getAuthUrl(provider, redirectTarget, requestsAppSession.value),
{
skip: Boolean(routeQuery.value.launcher || requestsAppSession.value),
prepare() {
const account = reauthAccount.value
if (!account || !import.meta.client) {
return
}
window.sessionStorage.setItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY, account.id)
},
const url = providerAuthUrl(provider, requestsAppSession.value)
if (!url) {
event.preventDefault()
return
}
beginProviderHandoff(provider, event, url, {
skip: Boolean(routeQuery.value.launcher || requestsAppSession.value),
prepare() {
const account = reauthAccount.value
if (!account || !import.meta.client) {
return
}
window.sessionStorage.setItem(LAUNCHER_REAUTH_ACCOUNT_STORAGE_KEY, account.id)
},
)
})
}

const { formatMessage } = useVIntl()
Expand Down
43 changes: 31 additions & 12 deletions apps/frontend/src/components/ui/auth/SignUp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,43 +11,49 @@
</div>
<section class="flex flex-col gap-2.5">
<ButtonLink
:href="getAuthUrl('google', redirectTarget, requestsAppSession)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl('google')"
@click="onOAuthProviderClick('google', $event)"
>
<GoogleColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Google' }) }}</span>
</ButtonLink>
<ButtonLink
:href="getAuthUrl('microsoft', redirectTarget, requestsAppSession)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl('microsoft')"
@click="onOAuthProviderClick('microsoft', $event)"
>
<MicrosoftColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Microsoft' }) }}</span>
</ButtonLink>
<ButtonLink
:href="getAuthUrl('discord', redirectTarget, requestsAppSession)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl('discord')"
@click="onOAuthProviderClick('discord', $event)"
>
<DiscordColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'Discord' }) }}</span>
</ButtonLink>
<template v-if="showOtherOptions">
<ButtonLink
:href="getAuthUrl('github', redirectTarget, requestsAppSession)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl('github')"
@click="onOAuthProviderClick('github', $event)"
>
<GitHubColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'GitHub' }) }}</span>
</ButtonLink>
<ButtonLink
:href="getAuthUrl('gitlab', redirectTarget, requestsAppSession)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl('gitlab')"
@click="onOAuthProviderClick('gitlab', $event)"
>
<GitLabColorIcon />
<span>{{ formatMessage(messages.continueWithProvider, { provider: 'GitLab' }) }}</span>
</ButtonLink>
<ButtonLink
:href="getAuthUrl('steam', redirectTarget, requestsAppSession)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl('steam')"
@click="onOAuthProviderClick('steam', $event)"
>
<SteamColorIcon />
Expand Down Expand Up @@ -166,6 +172,7 @@ import { getAuthUrl } from '@/composables/auth.ts'
import { isAuthEmbedded, useAuthNavigation } from '@/composables/auth-modal.ts'
import { useProviderOpening } from '@/composables/external-auth.ts'
import { isLauncherProtocolV2 } from '@/composables/launcher-auth.ts'
import { useOAuthCodeChallenge } from '@/composables/oauth-pkce.ts'
import type { OAuthProviderId } from '@/composables/oauth-providers.ts'

interface Props {
Expand Down Expand Up @@ -199,13 +206,25 @@ function openSignIn() {
})
}

const oauthCodeChallenge = useOAuthCodeChallenge()

function providerAuthUrl(provider: string) {
const challenge = oauthCodeChallenge.value
if (!challenge) {
return undefined
}
return getAuthUrl(provider, redirectTarget, requestsAppSession.value, challenge)
}

const onOAuthProviderClick = (provider: OAuthProviderId, event: MouseEvent) => {
beginProviderHandoff(
provider,
event,
getAuthUrl(provider, redirectTarget, requestsAppSession.value),
{ skip: Boolean(routeQuery.launcher || requestsAppSession.value) },
)
const url = providerAuthUrl(provider)
if (!url) {
event.preventDefault()
return
}
beginProviderHandoff(provider, event, url, {
skip: Boolean(routeQuery.launcher || requestsAppSession.value),
})
}

const { formatMessage } = useVIntl()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
type="colored"
color="brand"
class="!w-full !justify-center"
:href="getAuthUrl(oauthProvider.id, redirectTarget)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl(oauthProvider.id)"
:aria-label="
formatMessage(messages.signInWithProvider, { provider: oauthProvider.name })
"
Expand Down Expand Up @@ -103,7 +104,8 @@
v-for="provider in oauthProviders"
:key="provider.id"
class="relative w-full !justify-center"
:href="getAuthUrl(provider.id, redirectTarget)"
:disabled="!oauthCodeChallenge"
:href="providerAuthUrl(provider.id)"
:aria-label="formatMessage(messages.signInWithProvider, { provider: provider.name })"
@click="onOAuthProviderClick(provider.id, $event)"
>
Expand Down Expand Up @@ -220,6 +222,7 @@ import {
useAuthRoute,
} from '@/composables/auth-modal.ts'
import { useProviderOpening } from '@/composables/external-auth.ts'
import { useOAuthCodeChallenge } from '@/composables/oauth-pkce.ts'
import { type OAuthProviderId, oauthProviders } from '@/composables/oauth-providers.ts'
import { getPasskeyCredential } from '@/helpers/passkey.ts'

Expand Down Expand Up @@ -325,8 +328,23 @@ onMounted(() => {

const { openingProviderName, beginProviderHandoff, cancelProviderOpening } = useProviderOpening()

const oauthCodeChallenge = useOAuthCodeChallenge()

function providerAuthUrl(provider: string) {
const challenge = oauthCodeChallenge.value
if (!challenge) {
return undefined
}
return getAuthUrl(provider, redirectTarget.value, false, challenge)
}

function onOAuthProviderClick(provider: OAuthProviderId, event: MouseEvent) {
beginProviderHandoff(provider, event, getAuthUrl(provider, redirectTarget.value), {
const url = providerAuthUrl(provider)
if (!url) {
event.preventDefault()
return
}
beginProviderHandoff(provider, event, url, {
skip: Boolean(route.query.launcher),
})
}
Expand Down
38 changes: 10 additions & 28 deletions apps/frontend/src/composables/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { useAuthCookie } from '@/composables/auth-cookie.ts'
import { useAuthRoute } from '@/composables/auth-modal.ts'
import {
getQueryString,
isLauncherProtocolV2,
LAUNCHER_APP_SESSION_QUERY_PARAM,
LAUNCHER_AUTH_QUERY_PARAMS,
} from '@/composables/launcher-auth.ts'
Expand Down Expand Up @@ -70,7 +69,7 @@ export const useAuth = async (

export const initAuth = async (
oldToken: string | null | undefined = null,
route?: AuthInitRoute,
_route?: AuthInitRoute,
) => {
const auth: AuthState = {
user: null,
Expand All @@ -81,7 +80,6 @@ export const initAuth = async (
return auth
}

const resolvedRoute = route ?? useRoute()
const authCookie = useAuthCookie()

if (oldToken) {
Expand All @@ -91,29 +89,6 @@ export const initAuth = async (
}
}

const oauthCode = normalizeAuthToken(resolvedRoute.query.code)
const keepsExistingLauncherSession = isLauncherProtocolV2(resolvedRoute)
if (
oauthCode &&
!keepsExistingLauncherSession &&
!resolvedRoute.fullPath.includes('new_account=true')
) {
authCookie.value = oauthCode
}

if (
resolvedRoute.fullPath.includes('new_account=true') &&
resolvedRoute.path !== '/auth/welcome'
) {
const redirect = resolvedRoute.path.startsWith('/auth/') ? null : resolvedRoute.fullPath

await navigateTo(
`/auth/welcome?authToken=${oauthCode}${
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
}`,
)
}

const tokenStr = normalizeAuthToken(authCookie.value)
let shouldRefresh = false

Expand Down Expand Up @@ -207,7 +182,12 @@ export const getSignInRouteObj = (route: FullPathRoute, redirectOverride?: strin

export const ADD_ACCOUNT_QUERY_PARAM = 'add_account'

export const getAuthUrl = (provider: string, redirect?: string, includeAppSession = false) => {
export const getAuthUrl = (
provider: string,
redirect?: string,
includeAppSession = false,
codeChallenge?: string | null,
) => {
const config = useRuntimeConfig()
const route = useAuthRoute()
const launcher = getQueryString(route.query.launcher)
Expand Down Expand Up @@ -235,7 +215,9 @@ export const getAuthUrl = (provider: string, redirect?: string, includeAppSessio
callbackUrl.searchParams.set(ADD_ACCOUNT_QUERY_PARAM, 'true')
}

return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(callbackUrl.toString())}`
const challengeQuery = codeChallenge ? `&code_challenge=${encodeURIComponent(codeChallenge)}` : ''

return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(callbackUrl.toString())}${challengeQuery}`
}

export const promotePendingSignInOAuthProvider = () => {
Expand Down
88 changes: 88 additions & 0 deletions apps/frontend/src/composables/oauth-pkce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { onMounted, ref } from 'vue'

const VERIFIER_KEY = 'modrinth-oauth-pkce-verifier'
const LOGIN_CODE_PREFIX = 'mlc_'

const claimedLoginCodes = new Set<string>()

export function isLoginCode(code: string) {
return code.startsWith(LOGIN_CODE_PREFIX) && isLoginCodeBody(code.slice(LOGIN_CODE_PREFIX.length))
}

export function claimLoginCode(code: string) {
if (claimedLoginCodes.has(code)) {
return false
}
claimedLoginCodes.add(code)
return true
}

export function readOauthCodeVerifier() {
if (!import.meta.client) {
return null
}

const verifier = window.sessionStorage.getItem(VERIFIER_KEY)
if (!verifier || !isPkceVerifier(verifier)) {
return null
}
return verifier
}

export function clearOauthCodeVerifier() {
if (!import.meta.client) {
return
}
window.sessionStorage.removeItem(VERIFIER_KEY)
}

export async function ensureOauthCodeChallenge() {
let verifier = readOauthCodeVerifier()
if (!verifier) {
verifier = createVerifier()
window.sessionStorage.setItem(VERIFIER_KEY, verifier)
}
return codeChallengeForVerifier(verifier)
}

export function useOAuthCodeChallenge() {
const challenge = ref<string | null>(null)

onMounted(() => {
void ensureOauthCodeChallenge()
.then((value) => {
challenge.value = value
})
.catch((error: unknown) => {
console.error(error)
})
})

return challenge
}

function createVerifier() {
return bytesToBase64Url(crypto.getRandomValues(new Uint8Array(32)))
}

async function codeChallengeForVerifier(verifier: string) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
return bytesToBase64Url(new Uint8Array(digest))
}

function isPkceVerifier(value: string) {
return /^[A-Za-z0-9._~-]{43,128}$/.test(value)
}

function isLoginCodeBody(value: string) {
return /^[A-Za-z0-9]{43}$/.test(value)
}

function bytesToBase64Url(bytes: Uint8Array) {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}

return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
}
Loading
Loading