feat: update auth flow (#5790)

* Backend routes for choosing username in OAuth flow

* fix up oauth flow routes

* improve URL-related OAuth code

* Use user-provided callback addr instead of SELF_ADDR

* Revert "Use user-provided callback addr instead of SELF_ADDR"

This reverts commit 7ea0635d86.

* fix flow

* fix: backend response for create oauth account

* feat: new auth flow (#5840)

* update auth with new designs

* refactor: auth.js to auth.ts

* refactor: componentize auth pages

* fix: auth pages height

* feat: initial implementation of new sign-in oauth

* fix create account flow

* fix checkbox

* remove hard coded username

* implement create user validation endpoint and add more specific error responses

* feat: implement under 13 DOB guard and email/password validation route

* fix: TOCTOU issue

* refactor: pnpm prepr

* fix: make sure staging uses staging

* fix: hcaptcha styles

* fix: copy

* remove: auth/welcome page as its no longer used

* refactor: bring root page card styles into individual components and use tailwind

* fix: account settings modals to use new modal and fix lots of bad styles

* refactor: pnpm prepr

* feat: implement last signed in indicator

* fix: append number when generated name from email is taken

* refactor: pnpm prepr

* fix: last sign in badge color

* fix: qa issues

* refactor: pnpm prepr

* fix: hover effect on native date picker

* chore: temp staging undo

* Revert "chore: temp staging undo"

This reverts commit cad6bd4f92.

* feat: handle app create account

* fix: last signed in style

* fix: add initOnMounted for SSR race

* refactor: use typescript

* refactor: pnpm prepr

* refactor: use typescript for reset-password

* refactor: convert verify-email to use typescript

* refactor: convert authorize.vue to use typescript

* fix: authorize.vue error states

* feat: small style updates

* feat: implement date picker component

* feat: improve UX and styles for range select

* refactor: pnpm prepr

* fix: range select border styles

* feat: implement date picker component in create account

* feat: implement preserve date for date picker

* update rust toolchain

* increase recursion limit

* fix: date picker can be null

* fix: calculate age based on user's timezone

* fix: number input icons color

* fix: date picker icons

* feat: improve styles

* fix: add width on date

* fix: hover color bad on number input

* fix lints

* feat: add default date open view

* fmt

* fix: account.vue

* fix: remove default date to open 13 years ago

* fix: edit copy on info banner

* fix: cannot hover over project card tooltip items (#6071)

fix: cannot hover over project cards

* feat: improve add dependency flow (#6075)

* fix: shadow on nav

* feat: improve add dependency flow

* feat: update suggested dependency style

* feat: update dependency rows to use version number and update styles

* feat: implement combobox select searched text on focus

* feat: add Tabs.vue

* feat: update nav tabs to use tabs

* feat: improve project search dropdown

* fix: dependency search not clearing inbound query

* fix: combobox no options open state bug

* feat: improve dependency project and version search

* fix: open modrinth project links in the app (#6072)

* pin tanstack versions + set pnpm min age to 7 days

* squash commits

* fix: 2 factor auth enter code screen styles

* update copy

* update copy

* improve reset password

* feat: update sign in screen

* fix: unused import

* Merge branch 'main' into boris/dev-908-backend-changes

* Revert "Merge branch 'main' into boris/dev-908-backend-changes"

This reverts commit b9b03796e3.

* fix: add stroke

* feat: add passkey support (#6375)

* feat: add passkey backend

* feat: passkey frontend

* invalidate sessions on compromised passkey

* chore: run sqlx prepare

* fix: make passkey button use both collumns to prevent empty space

* fix: correctly verify max passkeys in finish route

* fix: use structs for response

* fix: add rp name default

* style: use web::Json

* fmt

* feat: improve manage passkeys UI

* fix copy

* pnpm prepr

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Michael H. <michael@iptables.sh>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: DeDiamondPro <67508414+DeDiamondPro@users.noreply.github.com>
This commit is contained in:
aecsocket
2026-06-25 20:36:19 +00:00
committed by GitHub
co-authored by tdgao Truman Gao Michael H. Calum H. Calum H. Prospector DeDiamondPro
parent 6fc741f7c0
commit ef4044534f
125 changed files with 5170 additions and 2828 deletions
-179
View File
@@ -1,179 +0,0 @@
function normalizeAuthToken(value) {
if (typeof value === 'string') {
return value
}
return ''
}
export const useAuth = async (oldToken = null) => {
const auth = useState('auth', () => ({
user: null,
token: '',
headers: {},
}))
if (!auth.value.user || oldToken) {
auth.value = await initAuth(oldToken)
}
return auth
}
export const initAuth = async (oldToken = null) => {
const auth = {
user: null,
token: '',
}
if (oldToken === 'none') {
return auth
}
const route = useRoute()
const config = useRuntimeConfig()
const authCookie = useCookie('auth-token', {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
secure: config.public.cookieSecure,
httpOnly: false,
path: '/',
})
if (oldToken) {
const normalized = normalizeAuthToken(oldToken)
if (normalized) {
authCookie.value = normalized
}
}
const oauthCode = normalizeAuthToken(route.query.code)
if (oauthCode && !route.fullPath.includes('new_account=true')) {
authCookie.value = oauthCode
}
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
await navigateTo(
`/auth/welcome?authToken=${oauthCode}${
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
}`,
)
}
const tokenStr = normalizeAuthToken(authCookie.value)
if (authCookie.value != null && tokenStr === '') {
authCookie.value = null
} else if (tokenStr) {
auth.token = tokenStr
if (!auth.token.startsWith('mra_')) {
return auth
}
try {
auth.user = await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
},
true,
)
} catch {
/* empty */
}
}
if (!auth.user && auth.token && typeof auth.token === 'string') {
try {
const session = await useBaseFetch(
'session/refresh',
{
method: 'POST',
headers: {
Authorization: auth.token,
},
},
true,
)
auth.token = normalizeAuthToken(session.session)
if (auth.token) {
authCookie.value = auth.token
auth.user = await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
},
true,
)
} else {
authCookie.value = null
auth.token = ''
}
} catch {
authCookie.value = null
}
}
return auth
}
export const getSignInRedirectPath = (route) => {
const fullPath = route.fullPath
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) {
return '/dashboard'
}
return fullPath
}
export const getSignInRouteObj = (route, redirectOverride) => ({
path: '/auth/sign-in',
query: {
redirect: redirectOverride ?? getSignInRedirectPath(route),
},
})
export const getAuthUrl = (provider, redirect = '/dashboard') => {
const config = useRuntimeConfig()
const route = useNativeRoute()
const fullURL = route.query.launcher
? getLauncherRedirectUrl(route)
: `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}`
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
}
export const removeAuthProvider = async (provider) => {
startLoading()
const auth = await useAuth()
await useBaseFetch('auth/provider', {
method: 'DELETE',
body: {
provider,
},
})
await useAuth(auth.value.token)
stopLoading()
}
export const getLauncherRedirectUrl = (route) => {
const usesLocalhostRedirectionScheme =
['4', '6'].includes(route.query.ipver) && Number(route.query.port) < 65536
return usesLocalhostRedirectionScheme
? `http://${route.query.ipver === '4' ? '127.0.0.1' : '[::1]'}:${route.query.port}`
: `https://launcher-files.modrinth.com`
}
+241
View File
@@ -0,0 +1,241 @@
import type { Labrinth } from '@modrinth/api-client'
import { useStorage } from '@vueuse/core'
import type { LocationQueryValue, RouteLocationNormalizedLoaded } from 'vue-router'
import type { CookieOptions } from '#app'
type AuthState = {
user: Labrinth.Users.v2.User | null
token: string
}
type QueryValue = LocationQueryValue | LocationQueryValue[] | undefined
type FullPathRoute = Pick<RouteLocationNormalizedLoaded, 'fullPath'>
type LauncherRoute = Pick<RouteLocationNormalizedLoaded, 'query'>
export const LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY = 'auth-last-sign-in-oauth-provider'
export const PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY = 'auth-pending-sign-in-oauth-provider'
const AUTH_COOKIE_OPTIONS = {
maxAge: 60 * 60 * 24 * 365 * 10,
sameSite: 'lax',
httpOnly: false,
path: '/',
} satisfies CookieOptions<string | null>
const normalizeAuthToken = (value: unknown) => {
if (typeof value === 'string') {
return value
}
return ''
}
const getQueryString = (value: QueryValue) => {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
export const useAuth = async (oldToken: string | null | undefined = null) => {
const auth = useState<AuthState>('auth', () => ({
user: null,
token: '',
}))
if (!auth.value.user || oldToken) {
auth.value = await initAuth(oldToken)
}
return auth
}
export const initAuth = async (oldToken: string | null | undefined = null) => {
const auth: AuthState = {
user: null,
token: '',
}
if (oldToken === 'none') {
return auth
}
const route = useRoute()
const config = useRuntimeConfig()
const authCookie = useCookie<string | null>('auth-token', {
...AUTH_COOKIE_OPTIONS,
secure: config.public.cookieSecure,
})
if (oldToken) {
const normalized = normalizeAuthToken(oldToken)
if (normalized) {
authCookie.value = normalized
}
}
const oauthCode = normalizeAuthToken(route.query.code)
if (oauthCode && !route.fullPath.includes('new_account=true')) {
authCookie.value = oauthCode
}
if (route.fullPath.includes('new_account=true') && route.path !== '/auth/welcome') {
const redirect = route.path.startsWith('/auth/') ? null : route.fullPath
await navigateTo(
`/auth/welcome?authToken=${oauthCode}${
redirect ? `&redirect=${encodeURIComponent(redirect)}` : ''
}`,
)
}
const tokenStr = normalizeAuthToken(authCookie.value)
if (authCookie.value != null && tokenStr === '') {
authCookie.value = null
} else if (tokenStr) {
auth.token = tokenStr
if (!auth.token.startsWith('mra_')) {
return auth
}
try {
auth.user = (await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
},
true,
)) as Labrinth.Users.v2.User
} catch {
/* empty */
}
}
if (!auth.user && auth.token) {
try {
const session = (await useBaseFetch(
'session/refresh',
{
method: 'POST',
headers: {
Authorization: auth.token,
},
},
true,
)) as { session: unknown }
auth.token = normalizeAuthToken(session.session)
if (auth.token) {
authCookie.value = auth.token
auth.user = (await useBaseFetch(
'user',
{
apiVersion: 3,
headers: {
Authorization: auth.token,
},
},
true,
)) as Labrinth.Users.v2.User
} else {
authCookie.value = null
auth.token = ''
}
} catch {
authCookie.value = null
}
}
return auth
}
export const getSignInRedirectPath = (route: FullPathRoute) => {
const fullPath = route.fullPath
if (fullPath === '/auth' || fullPath.startsWith('/auth/')) {
return '/dashboard'
}
return fullPath
}
export const getSignInRouteObj = (route: FullPathRoute, redirectOverride?: string | null) => ({
path: '/auth/sign-in',
query: {
redirect: redirectOverride ?? getSignInRedirectPath(route),
},
})
export const getAuthUrl = (provider: string, redirect = '/dashboard') => {
const config = useRuntimeConfig()
const route = useNativeRoute()
const launcher = getQueryString(route.query.launcher)
const fullURL = launcher
? (() => {
const callbackUrl = new URL('/auth/sign-in', config.public.siteUrl)
callbackUrl.searchParams.set('launcher', launcher)
const ipver = getQueryString(route.query.ipver)
const port = getQueryString(route.query.port)
if (ipver) {
callbackUrl.searchParams.set('ipver', ipver)
}
if (port) {
callbackUrl.searchParams.set('port', port)
}
return callbackUrl.toString()
})()
: `${config.public.siteUrl}/auth/sign-in?redirect=${encodeURIComponent(redirect)}`
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`
}
export const promotePendingSignInOAuthProvider = () => {
if (!import.meta.client) return
const pending = useStorage<string | null>(
PENDING_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY,
null,
undefined,
{ initOnMounted: true },
)
if (!pending.value) return
const last = useStorage<string | null>(LAST_SIGN_IN_OAUTH_PROVIDER_STORAGE_KEY, null, undefined, {
initOnMounted: true,
})
last.value = pending.value
pending.value = null
}
export const removeAuthProvider = async (provider: string) => {
startLoading()
const auth = await useAuth()
await useBaseFetch('auth/provider', {
method: 'DELETE',
body: {
provider,
},
})
await useAuth(auth.value.token)
stopLoading()
}
export const getLauncherRedirectUrl = (route: LauncherRoute) => {
const ipver = getQueryString(route.query.ipver)
const port = Number(getQueryString(route.query.port))
const usesLocalhostRedirectionScheme = ['4', '6'].includes(ipver ?? '') && port < 65536
return usesLocalhostRedirectionScheme
? `http://${ipver === '4' ? '127.0.0.1' : '[::1]'}:${port}`
: 'https://launcher-files.modrinth.com'
}