Implement direct login functionality with email and password
- Update login API to support direct authentication via email and password, removing the OAuth2 redirect flow. - Modify LoginForm component to include password field and validation. - Refactor useAuth composable to handle login with both email and password. - Remove unnecessary OAuth2 callback handler and PKCE utilities. - Update relevant documentation and error handling for the new login method.
This commit is contained in:
@@ -46,7 +46,10 @@
|
|||||||
"mcp__context7__get-library-docs",
|
"mcp__context7__get-library-docs",
|
||||||
"mcp__playwright__browser_click",
|
"mcp__playwright__browser_click",
|
||||||
"mcp__playwright__browser_type",
|
"mcp__playwright__browser_type",
|
||||||
"WebFetch(domain:www.shadcn-vue.com)"
|
"WebFetch(domain:www.shadcn-vue.com)",
|
||||||
|
"WebFetch(domain:docs.cidaas.com)",
|
||||||
|
"WebFetch(domain:articles.cidaas.de)",
|
||||||
|
"WebFetch(domain:pre-release-docs.cidaas.com)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { login } = useAuth()
|
|||||||
const loginSchema = toTypedSchema(
|
const loginSchema = toTypedSchema(
|
||||||
z.object({
|
z.object({
|
||||||
email: z.string().email('Bitte geben Sie eine gültige E-Mail-Adresse ein'),
|
email: z.string().email('Bitte geben Sie eine gültige E-Mail-Adresse ein'),
|
||||||
|
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ const onSubmit = handleSubmit(async (values) => {
|
|||||||
submitError.value = null
|
submitError.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(values.email)
|
await login(values.email, values.password)
|
||||||
// Redirect happens in login() function
|
// Redirect happens in login() function
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Login error:', error)
|
console.error('Login error:', error)
|
||||||
@@ -55,6 +56,17 @@ const onSubmit = handleSubmit(async (values) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
||||||
|
<!-- Password Field -->
|
||||||
|
<FormField v-slot="{ componentField }" name="password">
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel class="text-white/90 text-base font-medium">Passwort</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder="••••••••" v-bind="componentField" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
</FormField>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<Button type="submit" variant="experimenta" size="experimenta" class="w-full" :disabled="isSubmitting">
|
<Button type="submit" variant="experimenta" size="experimenta" class="w-full" :disabled="isSubmitting">
|
||||||
<Loader2 v-if="isSubmitting" class="mr-2 h-5 w-5 animate-spin" />
|
<Loader2 v-if="isSubmitting" class="mr-2 h-5 w-5 animate-spin" />
|
||||||
@@ -63,7 +75,7 @@ const onSubmit = handleSubmit(async (values) => {
|
|||||||
|
|
||||||
<!-- Info Text -->
|
<!-- Info Text -->
|
||||||
<p class="text-sm text-white/70 text-center">
|
<p class="text-sm text-white/70 text-center">
|
||||||
Sie werden zur sicheren Anmeldeseite weitergeleitet
|
Anmeldung erfolgt verschlüsselt über unseren Partner Cidaas
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -13,19 +13,26 @@ export function useAuth() {
|
|||||||
const { loggedIn, user, clear, fetch } = useUserSession()
|
const { loggedIn, user, clear, fetch } = useUserSession()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login with email
|
* Login with email and password
|
||||||
* Initiates OAuth2 flow
|
* Direct authentication via Cidaas API (no redirect)
|
||||||
*/
|
*/
|
||||||
async function login(email: string) {
|
async function login(email: string, password: string) {
|
||||||
try {
|
try {
|
||||||
// Call login endpoint to get redirect URL
|
// Call login endpoint - creates session directly
|
||||||
const { redirectUrl } = await $fetch('/api/auth/login', {
|
await $fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { email },
|
body: { email, password },
|
||||||
})
|
})
|
||||||
|
|
||||||
// Redirect to Cidaas
|
// Refresh user session
|
||||||
navigateTo(redirectUrl, { external: true })
|
await fetch()
|
||||||
|
|
||||||
|
// Redirect to homepage or saved destination
|
||||||
|
const redirectAfterLogin = useCookie('redirect_after_login')
|
||||||
|
const destination = redirectAfterLogin.value || '/'
|
||||||
|
redirectAfterLogin.value = null // Clear cookie
|
||||||
|
|
||||||
|
navigateTo(destination)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login failed:', error)
|
console.error('Login failed:', error)
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ definePageMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<CommonHeader />
|
||||||
|
|
||||||
<div class="container mx-auto max-w-lg px-4 py-12 sm:py-16">
|
<div class="container mx-auto max-w-lg px-4 py-12 sm:py-16">
|
||||||
<div class="mb-10 text-center">
|
<div class="mb-10 text-center">
|
||||||
<h1 class="text-4xl font-light tracking-tight">
|
<h1 class="text-4xl font-light tracking-tight">
|
||||||
@@ -102,4 +104,6 @@ definePageMeta({
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CommonFooter />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
// server/api/auth/callback.get.ts
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/auth/callback
|
|
||||||
*
|
|
||||||
* OAuth2 callback handler - receives authorization code from Cidaas
|
|
||||||
*
|
|
||||||
* Query params:
|
|
||||||
* - code: Authorization code
|
|
||||||
* - state: CSRF protection token
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Validate state parameter
|
|
||||||
* 2. Exchange code for tokens
|
|
||||||
* 3. Validate ID token
|
|
||||||
* 4. Fetch user info
|
|
||||||
* 5. Create/update user in PostgreSQL
|
|
||||||
* 6. Create session
|
|
||||||
* 7. Redirect to homepage
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { eq } from 'drizzle-orm'
|
|
||||||
import { users } from '../../database/schema'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
// 1. Extract query parameters
|
|
||||||
const query = getQuery(event)
|
|
||||||
const { code, state } = query
|
|
||||||
|
|
||||||
if (!code || !state) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Missing code or state parameter',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Validate state (CSRF protection)
|
|
||||||
const storedState = getCookie(event, 'oauth_state')
|
|
||||||
|
|
||||||
if (!storedState || state !== storedState) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'Invalid state parameter - possible CSRF attack',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Retrieve PKCE verifier
|
|
||||||
const verifier = getCookie(event, 'pkce_verifier')
|
|
||||||
|
|
||||||
if (!verifier) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 400,
|
|
||||||
statusMessage: 'PKCE verifier not found - session expired',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 4. Exchange authorization code for tokens
|
|
||||||
const tokens = await exchangeCodeForToken(code as string, verifier)
|
|
||||||
|
|
||||||
// 5. Validate ID token (JWT)
|
|
||||||
const idTokenPayload = await verifyIdToken(tokens.id_token)
|
|
||||||
|
|
||||||
// 6. Fetch detailed user info from Cidaas
|
|
||||||
const cidaasUser = await fetchUserInfo(tokens.access_token)
|
|
||||||
|
|
||||||
// 7. Get database instance
|
|
||||||
const db = useDatabase()
|
|
||||||
|
|
||||||
// 8. Check if user already exists in our database
|
|
||||||
let user = await db.query.users.findFirst({
|
|
||||||
where: eq(users.experimentaId, cidaasUser.sub),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
// First time login - create new user
|
|
||||||
const [newUser] = await db
|
|
||||||
.insert(users)
|
|
||||||
.values({
|
|
||||||
experimentaId: cidaasUser.sub, // Cidaas user ID
|
|
||||||
email: cidaasUser.email,
|
|
||||||
firstName: cidaasUser.given_name || null,
|
|
||||||
lastName: cidaasUser.family_name || null,
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
|
|
||||||
user = newUser
|
|
||||||
|
|
||||||
console.log('New user created:', user.id)
|
|
||||||
} else {
|
|
||||||
// Existing user - update last login timestamp
|
|
||||||
await db.update(users).set({ updatedAt: new Date() }).where(eq(users.id, user.id))
|
|
||||||
|
|
||||||
console.log('User logged in:', user.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. Create encrypted session (nuxt-auth-utils)
|
|
||||||
await setUserSession(event, {
|
|
||||||
user: {
|
|
||||||
id: user.id,
|
|
||||||
experimentaId: user.experimentaId,
|
|
||||||
email: user.email,
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
},
|
|
||||||
loggedInAt: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// 10. Clean up temporary cookies
|
|
||||||
deleteCookie(event, 'oauth_state')
|
|
||||||
deleteCookie(event, 'pkce_verifier')
|
|
||||||
|
|
||||||
// 11. Redirect to homepage (or original requested page)
|
|
||||||
const redirectTo = getCookie(event, 'redirect_after_login') || '/'
|
|
||||||
deleteCookie(event, 'redirect_after_login')
|
|
||||||
|
|
||||||
return sendRedirect(event, redirectTo)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OAuth callback error:', error)
|
|
||||||
|
|
||||||
// Clean up cookies on error
|
|
||||||
deleteCookie(event, 'oauth_state')
|
|
||||||
deleteCookie(event, 'pkce_verifier')
|
|
||||||
|
|
||||||
// Redirect to login page with error
|
|
||||||
return sendRedirect(event, '/auth?error=login_failed')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -3,71 +3,105 @@
|
|||||||
/**
|
/**
|
||||||
* POST /api/auth/login
|
* POST /api/auth/login
|
||||||
*
|
*
|
||||||
* Initiates OAuth2 Authorization Code Flow with PKCE
|
* Direct login with email and password (no OAuth2 redirect)
|
||||||
*
|
*
|
||||||
* Request body:
|
* Request body:
|
||||||
* {
|
* {
|
||||||
* "email": "user@example.com"
|
* "email": "user@example.com",
|
||||||
|
* "password": "SecureP@ssw0rd"
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* Response:
|
* Response:
|
||||||
* {
|
* {
|
||||||
* "redirectUrl": "https://experimenta.cidaas.de/authz-srv/authz?..."
|
* "success": true
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* Client should redirect user to redirectUrl
|
* Creates session cookie on success
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email('Invalid email address'),
|
email: z.string().email('Invalid email address'),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
// 1. Validate request body
|
// 1. Validate request body
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const { email } = loginSchema.parse(body)
|
const { email, password } = loginSchema.parse(body)
|
||||||
|
|
||||||
// 2. Generate PKCE challenge
|
try {
|
||||||
const { verifier, challenge } = await generatePKCE()
|
// 2. Authenticate with Cidaas (Resource Owner Password Credentials flow)
|
||||||
|
const tokens = await loginWithPassword(email, password)
|
||||||
|
|
||||||
// 3. Generate state for CSRF protection
|
// 3. Validate ID token (JWT)
|
||||||
const state = generateState(32)
|
const idTokenPayload = await verifyIdToken(tokens.id_token)
|
||||||
|
|
||||||
// 4. Store PKCE verifier in encrypted cookie (5 min TTL)
|
// 4. Fetch user info from Cidaas
|
||||||
setCookie(event, 'pkce_verifier', verifier, {
|
const cidaasUser = await fetchUserInfo(tokens.access_token)
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 300, // 5 minutes
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 5. Store state in cookie for validation
|
// 5. Create/update user in local database
|
||||||
setCookie(event, 'oauth_state', state, {
|
const db = useDatabase()
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 300, // 5 minutes
|
|
||||||
path: '/',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 6. Build Cidaas authorization URL
|
let user = await db.query.users.findFirst({
|
||||||
const config = useRuntimeConfig()
|
where: eq(users.experimentaId, cidaasUser.sub),
|
||||||
const authUrl = new URL(config.cidaas.authorizeUrl)
|
})
|
||||||
|
|
||||||
authUrl.searchParams.set('client_id', config.cidaas.clientId)
|
if (!user) {
|
||||||
authUrl.searchParams.set('redirect_uri', config.cidaas.redirectUri)
|
// First time login - create user profile
|
||||||
authUrl.searchParams.set('response_type', 'code')
|
const [newUser] = await db
|
||||||
authUrl.searchParams.set('scope', 'openid profile email')
|
.insert(users)
|
||||||
authUrl.searchParams.set('state', state)
|
.values({
|
||||||
authUrl.searchParams.set('code_challenge', challenge)
|
experimentaId: cidaasUser.sub,
|
||||||
authUrl.searchParams.set('code_challenge_method', 'S256')
|
email: cidaasUser.email,
|
||||||
authUrl.searchParams.set('login_hint', email) // Pre-fill email in Cidaas form
|
firstName: cidaasUser.given_name || '',
|
||||||
|
lastName: cidaasUser.family_name || '',
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
// 7. Return redirect URL to client
|
user = newUser
|
||||||
return {
|
} else {
|
||||||
redirectUrl: authUrl.toString(),
|
// Update last login timestamp
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, user.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Create encrypted session
|
||||||
|
await setUserSession(event, {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
experimentaId: user.experimentaId,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
},
|
||||||
|
loggedInAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 7. Return success
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
|
||||||
|
// Handle specific error cases
|
||||||
|
if (error.statusCode === 401) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Ungültige E-Mail-Adresse oder Passwort',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Anmeldung fehlgeschlagen',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -218,6 +218,74 @@ export async function registerUser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login with username and password (Resource Owner Password Credentials Flow)
|
||||||
|
*
|
||||||
|
* @param email - User email address
|
||||||
|
* @param password - User password
|
||||||
|
* @returns Token response with access_token and id_token
|
||||||
|
* @throws H3Error if login fails
|
||||||
|
*/
|
||||||
|
export async function loginWithPassword(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<CidaasTokenResponse> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
|
// Prepare token request with password grant
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
grant_type: 'password',
|
||||||
|
username: email, // Cidaas uses 'username' field for email
|
||||||
|
password,
|
||||||
|
client_id: config.cidaas.clientId,
|
||||||
|
client_secret: config.cidaas.clientSecret,
|
||||||
|
scope: 'openid profile email', // Request OIDC scopes
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(config.cidaas.tokenUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}))
|
||||||
|
console.error('Cidaas password login failed:', errorData)
|
||||||
|
|
||||||
|
// Handle specific errors
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Invalid email or password',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: response.status,
|
||||||
|
statusMessage: 'Login failed',
|
||||||
|
data: errorData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens: CidaasTokenResponse = await response.json()
|
||||||
|
return tokens
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password login error:', error)
|
||||||
|
|
||||||
|
if ((error as H3Error).statusCode) {
|
||||||
|
throw error // Re-throw H3Error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: 'Failed to authenticate with Cidaas',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh access token using refresh token
|
* Refresh access token using refresh token
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
// server/utils/pkce.ts
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PKCE (Proof Key for Code Exchange) utilities for OAuth2 security.
|
|
||||||
*
|
|
||||||
* PKCE prevents authorization code interception attacks by requiring
|
|
||||||
* the client to prove possession of the original code verifier.
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. Generate random code_verifier (43-128 chars)
|
|
||||||
* 2. Hash verifier with SHA-256 → code_challenge
|
|
||||||
* 3. Send challenge to authorization server
|
|
||||||
* 4. Server returns authorization code
|
|
||||||
* 5. Exchange code + verifier for tokens
|
|
||||||
* 6. Server validates: SHA256(verifier) === stored_challenge
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a random code verifier (43-128 URL-safe characters)
|
|
||||||
*
|
|
||||||
* @param length - Length of verifier (default: 64)
|
|
||||||
* @returns Base64URL-encoded random string
|
|
||||||
*/
|
|
||||||
export function generateCodeVerifier(length: number = 64): string {
|
|
||||||
// Generate random bytes
|
|
||||||
const randomBytes = crypto.getRandomValues(new Uint8Array(length))
|
|
||||||
|
|
||||||
// Convert to base64url (URL-safe base64 without padding)
|
|
||||||
return base64UrlEncode(randomBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate SHA-256 hash of code verifier → code challenge
|
|
||||||
*
|
|
||||||
* @param verifier - The code verifier
|
|
||||||
* @returns Base64URL-encoded SHA-256 hash
|
|
||||||
*/
|
|
||||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
|
||||||
// Convert verifier string to Uint8Array
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const data = encoder.encode(verifier)
|
|
||||||
|
|
||||||
// Hash with SHA-256
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
|
||||||
|
|
||||||
// Convert to base64url
|
|
||||||
return base64UrlEncode(new Uint8Array(hashBuffer))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate PKCE verifier + challenge pair
|
|
||||||
*
|
|
||||||
* @returns Object with verifier and challenge
|
|
||||||
*/
|
|
||||||
export async function generatePKCE(): Promise<{
|
|
||||||
verifier: string
|
|
||||||
challenge: string
|
|
||||||
}> {
|
|
||||||
const verifier = generateCodeVerifier()
|
|
||||||
const challenge = await generateCodeChallenge(verifier)
|
|
||||||
|
|
||||||
return { verifier, challenge }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert Uint8Array to Base64URL string
|
|
||||||
*
|
|
||||||
* Base64URL is URL-safe variant of Base64:
|
|
||||||
* - Replace '+' with '-'
|
|
||||||
* - Replace '/' with '_'
|
|
||||||
* - Remove padding '='
|
|
||||||
*/
|
|
||||||
function base64UrlEncode(buffer: Uint8Array): string {
|
|
||||||
// Convert to base64
|
|
||||||
const base64 = btoa(String.fromCharCode(...buffer))
|
|
||||||
|
|
||||||
// Convert to base64url
|
|
||||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate random state parameter for CSRF protection
|
|
||||||
*
|
|
||||||
* @param length - Length of state string (default: 32)
|
|
||||||
* @returns Random URL-safe string
|
|
||||||
*/
|
|
||||||
export function generateState(length: number = 32): string {
|
|
||||||
const randomBytes = crypto.getRandomValues(new Uint8Array(length))
|
|
||||||
return base64UrlEncode(randomBytes)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user