feat: add profile auth identity binding flow
This commit is contained in:
@@ -12,6 +12,8 @@ describe('oauth adoption auth api', () => {
|
||||
beforeEach(() => {
|
||||
post.mockReset()
|
||||
post.mockResolvedValue({ data: {} })
|
||||
localStorage.clear()
|
||||
document.cookie = 'oauth_bind_access_token=; Max-Age=0; path=/'
|
||||
})
|
||||
|
||||
it('posts adoption decisions when exchanging pending oauth completion', async () => {
|
||||
@@ -57,4 +59,43 @@ describe('oauth adoption auth api', () => {
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('posts wechat invitation completion with adoption decisions', async () => {
|
||||
const { completeWeChatOAuthRegistration } = await import('@/api/auth')
|
||||
|
||||
await completeWeChatOAuthRegistration('invite-code', {
|
||||
adoptDisplayName: true,
|
||||
adoptAvatar: true
|
||||
})
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: 'invite-code',
|
||||
adopt_display_name: true,
|
||||
adopt_avatar: true
|
||||
})
|
||||
})
|
||||
|
||||
it('classifies oauth completion results as login or bind', async () => {
|
||||
const { getOAuthCompletionKind } = await import('@/api/auth')
|
||||
|
||||
expect(getOAuthCompletionKind({ access_token: 'access-token' })).toBe('login')
|
||||
expect(getOAuthCompletionKind({ redirect: '/profile' })).toBe('bind')
|
||||
})
|
||||
|
||||
it('prepares an oauth bind access token cookie before redirect binding', async () => {
|
||||
localStorage.setItem('auth_token', 'access-token-value')
|
||||
const setCookie = vi.fn()
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
configurable: true,
|
||||
get: () => '',
|
||||
set: setCookie
|
||||
})
|
||||
|
||||
const { prepareOAuthBindAccessTokenCookie } = await import('@/api/auth')
|
||||
|
||||
prepareOAuthBindAccessTokenCookie()
|
||||
|
||||
expect(setCookie).toHaveBeenCalledTimes(1)
|
||||
expect(setCookie.mock.calls[0]?.[0]).toContain('oauth_bind_access_token=access-token-value')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -186,11 +186,14 @@ export interface RefreshTokenResponse {
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export interface PendingOAuthExchangeResponse {
|
||||
access_token?: string
|
||||
export interface OAuthTokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export interface PendingOAuthExchangeResponse extends Partial<OAuthTokenResponse> {
|
||||
redirect?: string
|
||||
error?: string
|
||||
adoption_required?: boolean
|
||||
@@ -198,6 +201,8 @@ export interface PendingOAuthExchangeResponse {
|
||||
suggested_avatar_url?: string
|
||||
}
|
||||
|
||||
export type OAuthCompletionKind = 'login' | 'bind'
|
||||
|
||||
export interface OAuthAdoptionDecision {
|
||||
adoptDisplayName?: boolean
|
||||
adoptAvatar?: boolean
|
||||
@@ -218,6 +223,56 @@ function serializeOAuthAdoptionDecision(
|
||||
return payload
|
||||
}
|
||||
|
||||
export function isOAuthLoginCompletion(
|
||||
completion: Partial<OAuthTokenResponse>
|
||||
): completion is OAuthTokenResponse {
|
||||
return typeof completion.access_token === 'string' && completion.access_token.trim().length > 0
|
||||
}
|
||||
|
||||
export function getOAuthCompletionKind(
|
||||
completion: Partial<OAuthTokenResponse>
|
||||
): OAuthCompletionKind {
|
||||
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
|
||||
}
|
||||
|
||||
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
|
||||
if (tokens.refresh_token) {
|
||||
setRefreshToken(tokens.refresh_token)
|
||||
}
|
||||
if (tokens.expires_in) {
|
||||
setTokenExpiresAt(tokens.expires_in)
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareOAuthBindAccessTokenCookie(): void {
|
||||
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
const token = getAuthToken()
|
||||
if (!token) {
|
||||
return
|
||||
}
|
||||
|
||||
const secure = window.location.protocol === 'https:' ? '; Secure' : ''
|
||||
const path = resolveOAuthBindCookiePath()
|
||||
document.cookie =
|
||||
`oauth_bind_access_token=${encodeURIComponent(token)}; Path=${path}/auth/oauth; Max-Age=600; SameSite=Lax${secure}`
|
||||
}
|
||||
|
||||
function resolveOAuthBindCookiePath(): string {
|
||||
const apiBase = ((import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1').replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
return new URL(apiBase, window.location.origin).pathname.replace(/\/$/, '') || '/api/v1'
|
||||
} catch {
|
||||
if (apiBase.startsWith('/')) {
|
||||
return apiBase
|
||||
}
|
||||
return '/api/v1'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the access token using the refresh token
|
||||
* @returns New token pair
|
||||
@@ -375,13 +430,8 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
|
||||
export async function completeLinuxDoOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}>('/auth/oauth/linuxdo/complete-registration', {
|
||||
): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/linuxdo/complete-registration', {
|
||||
invitation_code: invitationCode,
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
})
|
||||
@@ -396,13 +446,19 @@ export async function completeLinuxDoOAuthRegistration(
|
||||
export async function completeOIDCOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }> {
|
||||
const { data } = await apiClient.post<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}>('/auth/oauth/oidc/complete-registration', {
|
||||
): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/oidc/complete-registration', {
|
||||
invitation_code: invitationCode,
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function completeWeChatOAuthRegistration(
|
||||
invitationCode: string,
|
||||
decision?: OAuthAdoptionDecision
|
||||
): Promise<OAuthTokenResponse> {
|
||||
const { data } = await apiClient.post<OAuthTokenResponse>('/auth/oauth/wechat/complete-registration', {
|
||||
invitation_code: invitationCode,
|
||||
...serializeOAuthAdoptionDecision(decision)
|
||||
})
|
||||
@@ -444,7 +500,8 @@ export const authAPI = {
|
||||
revokeAllSessions,
|
||||
exchangePendingOAuthCompletion,
|
||||
completeLinuxDoOAuthRegistration,
|
||||
completeOIDCOAuthRegistration
|
||||
completeOIDCOAuthRegistration,
|
||||
completeWeChatOAuthRegistration
|
||||
}
|
||||
|
||||
export default authAPI
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from './client'
|
||||
import type { User, ChangePasswordRequest, NotifyEmailEntry } from '@/types'
|
||||
import { prepareOAuthBindAccessTokenCookie } from './auth'
|
||||
import type { User, ChangePasswordRequest, NotifyEmailEntry, UserAuthProvider } from '@/types'
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
@@ -83,6 +84,49 @@ export async function toggleNotifyEmail(email: string, disabled: boolean): Promi
|
||||
return data
|
||||
}
|
||||
|
||||
export type BindableOAuthProvider = Exclude<UserAuthProvider, 'email'>
|
||||
|
||||
interface BuildOAuthBindingStartURLOptions {
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function resolveWeChatOAuthMode(): 'open' | 'mp' {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return 'open'
|
||||
}
|
||||
return /MicroMessenger/i.test(navigator.userAgent) ? 'mp' : 'open'
|
||||
}
|
||||
|
||||
export function buildOAuthBindingStartURL(
|
||||
provider: BindableOAuthProvider,
|
||||
options: BuildOAuthBindingStartURLOptions = {}
|
||||
): string {
|
||||
const redirectTo = options.redirectTo?.trim() || '/profile'
|
||||
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
|
||||
const normalized = apiBase.replace(/\/$/, '')
|
||||
const params = new URLSearchParams({
|
||||
redirect: redirectTo,
|
||||
intent: 'bind_current_user'
|
||||
})
|
||||
|
||||
if (provider === 'wechat') {
|
||||
params.set('mode', resolveWeChatOAuthMode())
|
||||
}
|
||||
|
||||
return `${normalized}/auth/oauth/${provider}/start?${params.toString()}`
|
||||
}
|
||||
|
||||
export function startOAuthBinding(
|
||||
provider: BindableOAuthProvider,
|
||||
options: BuildOAuthBindingStartURLOptions = {}
|
||||
): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
prepareOAuthBindAccessTokenCookie()
|
||||
window.location.href = buildOAuthBindingStartURL(provider, options)
|
||||
}
|
||||
|
||||
export const userAPI = {
|
||||
getProfile,
|
||||
updateProfile,
|
||||
@@ -90,7 +134,9 @@ export const userAPI = {
|
||||
sendNotifyEmailCode,
|
||||
verifyNotifyEmail,
|
||||
removeNotifyEmail,
|
||||
toggleNotifyEmail
|
||||
toggleNotifyEmail,
|
||||
buildOAuthBindingStartURL,
|
||||
startOAuthBinding
|
||||
}
|
||||
|
||||
export default userAPI
|
||||
|
||||
Reference in New Issue
Block a user