[READ-ONLY] One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn
nextjs
0

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #280 from EvanTechDev/feature/fix-blue-timeline-position-on-view

Auth refactor and email sender; crypto key caching; Saturday-first week + UI tweaks

author
Evan Huang
committer
GitHub
date (May 22, 2026, 11:46 PM +0800) commit 6ddc2320 parent 78f81ccb
+178 -255
+1 -1
app/api/account/route.ts
··· 1 1 import { NextResponse } from 'next/server' 2 2 import { withEvlog, useLogger, getAuditActor } from '@/lib/evlog' 3 - import { getServerSession } from '@/lib/auth-server' 3 + import { getServerSession } from '@/lib/auth/server' 4 4 import { db } from '@/lib/drizzle/client' 5 5 import { shares, calendarBackups } from '@/lib/drizzle/schema' 6 6 import { eq, sql } from 'drizzle-orm'
+1 -1
app/api/blob/route.ts
··· 1 1 import { NextRequest, NextResponse } from 'next/server' 2 2 import { withEvlog, useLogger, getAuditActor } from '@/lib/evlog' 3 - import { getServerSession } from '@/lib/auth-server' 3 + import { getServerSession } from '@/lib/auth/server' 4 4 import { db } from '@/lib/drizzle/client' 5 5 import { calendarBackups } from '@/lib/drizzle/schema' 6 6 import { eq } from 'drizzle-orm'
+1 -1
app/api/share/list/route.ts
··· 1 1 import { type NextRequest, NextResponse } from 'next/server' 2 2 import { withEvlog, useLogger, getAuditActor } from '@/lib/evlog' 3 - import { getServerSession } from '@/lib/auth-server' 3 + import { getServerSession } from '@/lib/auth/server' 4 4 import crypto from 'crypto' 5 5 import { db } from '@/lib/drizzle/client' 6 6 import { shares } from '@/lib/drizzle/schema'
+1 -1
app/api/share/route.ts
··· 1 1 import { type NextRequest, NextResponse } from 'next/server' 2 2 import { withEvlog, useLogger, getAuditActor } from '@/lib/evlog' 3 - import { getServerSession } from '@/lib/auth-server' 3 + import { getServerSession } from '@/lib/auth/server' 4 4 import crypto from 'crypto' 5 5 import { db } from '@/lib/drizzle/client' 6 6 import { shares } from '@/lib/drizzle/schema'
+1 -1
components/app/calendar-types.ts
··· 2 2 3 3 export type ViewType = CalendarViewType | 'analytics' | 'settings' 4 4 5 - export type FirstDayOfWeek = 0 | 1 5 + export type FirstDayOfWeek = 0 | 1 | 6 6 6 7 7 const calendarViews = ['day', 'week', 'four-day', 'month', 'year'] as const 8 8
+7 -2
components/app/calendar.tsx
··· 135 135 'first-day-of-week', 136 136 0, 137 137 ) 138 - const normalizedFirstDayOfWeek: FirstDayOfWeek = firstDayOfWeek === 1 ? 1 : 0 138 + const normalizedFirstDayOfWeek: FirstDayOfWeek = 139 + firstDayOfWeek === 1 || firstDayOfWeek === 6 ? firstDayOfWeek : 0 139 140 140 141 const handleFirstDayOfWeekChange = (day: FirstDayOfWeek) => { 141 142 setFirstDayOfWeek(day) ··· 205 206 readEncryptedLocalStorage<FirstDayOfWeek>('first-day-of-week', 0), 206 207 readEncryptedLocalStorage<CalendarViewType>('default-view', 'week'), 207 208 ]).then(([restoredFirstDayOfWeek, restoredDefaultView]) => { 208 - setFirstDayOfWeek(restoredFirstDayOfWeek === 1 ? 1 : 0) 209 + setFirstDayOfWeek( 210 + restoredFirstDayOfWeek === 1 || restoredFirstDayOfWeek === 6 211 + ? restoredFirstDayOfWeek 212 + : 0, 213 + ) 209 214 if (isCalendarView(restoredDefaultView)) { 210 215 setDefaultView(restoredDefaultView) 211 216 setView(restoredDefaultView)
+1 -1
components/app/event/event-preview.tsx
··· 43 43 import { Checkbox } from '@/components/ui/checkbox' 44 44 import { toast } from 'sonner' 45 45 import QRCodeStyling from 'qr-code-styling' 46 - import { authClient } from '@/lib/auth-client' 46 + import { authClient } from '@/lib/auth/client' 47 47 48 48 interface EventPreviewProps { 49 49 event: CalendarEvent | null
+5 -1
components/app/profile/settings.tsx
··· 198 198 <Label htmlFor="first-day">{t.firstDayOfWeek}</Label> 199 199 <Select 200 200 value={firstDayOfWeek.toString()} 201 - onValueChange={(value) => setFirstDayOfWeek(value === '1' ? 1 : 0)} 201 + onValueChange={(value) => { 202 + const day = Number(value) 203 + setFirstDayOfWeek(day === 0 || day === 1 || day === 6 ? day : 0) 204 + }} 202 205 > 203 206 <SelectTrigger id="first-day"> 204 207 <SelectValue /> ··· 206 209 <SelectContent> 207 210 <SelectItem value="0">{t.sunday}</SelectItem> 208 211 <SelectItem value="1">{t.monday}</SelectItem> 212 + <SelectItem value="6">{t.saturday}</SelectItem> 209 213 </SelectContent> 210 214 </Select> 211 215 </div>
+24 -15
components/app/profile/user-profile-button.tsx
··· 45 45 import { toast } from 'sonner' 46 46 import { useCalendar } from '@/components/providers/calendar-context' 47 47 import { translations, useLanguage } from '@/lib/i18n' 48 - import { authClient } from '@/lib/auth-client' 48 + import { authClient } from '@/lib/auth/client' 49 49 import { useRouter } from 'next/navigation' 50 50 import QRCodeStyling from 'qr-code-styling' 51 51 import { ··· 148 148 async function normalizeCloudStorageValue( 149 149 value: string, 150 150 password: string, 151 + keyCache?: Map<string, Promise<CryptoKey>>, 151 152 ): Promise<string> { 152 153 try { 153 154 const parsed = JSON.parse(value) ··· 155 156 return value 156 157 } 157 158 158 - return await decryptPayload(password, parsed.ciphertext, parsed.iv) 159 + return decryptWithDerivedKey( 160 + password, 161 + parsed.ciphertext, 162 + parsed.iv, 163 + keyCache, 164 + ) 159 165 } catch { 160 166 return value 161 167 } ··· 553 559 const cloud = await apiGet() 554 560 if (!cloud) return 555 561 562 + const keyCache = new Map<string, Promise<CryptoKey>>() 563 + 556 564 let plain 557 565 try { 558 566 plain = await decryptPayload(password, cloud.ciphertext, cloud.iv) ··· 568 576 await Promise.all( 569 577 Object.entries(data.storage).map(async ([key, value]) => [ 570 578 key, 571 - await normalizeCloudStorageValue(String(value), password), 579 + await normalizeCloudStorageValue( 580 + String(value), 581 + password, 582 + keyCache, 583 + ), 572 584 ]), 573 585 ), 574 586 ) ··· 588 600 await setEncryptionPassword(password) 589 601 } 590 602 591 - const restoredEvents = await readEncryptedLocalStorage( 592 - 'calendar-events', 593 - [], 594 - ) 595 - const restoredCalendars = await readEncryptedLocalStorage( 596 - 'calendar-categories', 597 - [], 598 - ) 599 - const restoredLanguage = await readEncryptedLocalStorage<string | null>( 600 - 'preferred-language', 601 - null, 602 - ) 603 + const [restoredEvents, restoredCalendars, restoredLanguage] = 604 + await Promise.all([ 605 + readEncryptedLocalStorage('calendar-events', []), 606 + readEncryptedLocalStorage('calendar-categories', []), 607 + readEncryptedLocalStorage<string | null>( 608 + 'preferred-language', 609 + null, 610 + ), 611 + ]) 603 612 setEvents(restoredEvents) 604 613 setCalendars(restoredCalendars) 605 614 if (restoredLanguage) {
+1 -1
components/app/views/day-view.tsx
··· 900 900 901 901 return ( 902 902 <div 903 - className="absolute left-0 right-0 border-t-2 border-[#0066FF] z-0 green:border-[#24a854] orange:border-[#e26912] azalea:border-[#CD2F7B]" 903 + className="absolute left-0 right-0 border-t-2 border-[#0066FF] z-30 pointer-events-none green:border-[#24a854] orange:border-[#e26912] azalea:border-[#CD2F7B]" 904 904 style={{ 905 905 top: `${topPosition}px`, 906 906 }}
+1 -1
components/app/views/week-view.tsx
··· 1021 1021 1022 1022 return ( 1023 1023 <div 1024 - className="absolute left-0 right-0 border-t-2 border-[#0066FF] z-0 green:border-[#24a854] orange:border-[#e26912] azalea:border-[#CD2F7B]" 1024 + className="absolute left-0 right-0 border-t-2 border-[#0066FF] z-30 pointer-events-none green:border-[#24a854] orange:border-[#e26912] azalea:border-[#CD2F7B]" 1025 1025 style={{ 1026 1026 top: `${topPosition}px`, 1027 1027 }}
+1 -1
components/auth/login-form.tsx
··· 15 15 import { Input } from '@/components/ui/input' 16 16 import { InputOTP } from '@/components/ui/input-otp' 17 17 import { Label } from '@/components/ui/label' 18 - import { authClient } from '@/lib/auth-client' 18 + import { authClient } from '@/lib/auth/client' 19 19 import { cn } from '@/lib/utils' 20 20 21 21 export function LoginForm({
-57
components/auth/oauth-provider-icon.tsx
··· 1 - 'use client' 2 - 3 - import type { OAuthProviderKey } from '@/lib/clerk-oauth' 4 - 5 - interface OAuthProviderIconProps { 6 - providerKey: OAuthProviderKey 7 - className?: string 8 - } 9 - 10 - export function OAuthProviderIcon({ 11 - providerKey, 12 - className = 'h-5 w-5', 13 - }: OAuthProviderIconProps) { 14 - if (providerKey === 'microsoft') { 15 - return ( 16 - <svg viewBox="0 0 23 23" className={className} aria-hidden="true"> 17 - <path fill="#f25022" d="M1 1h10v10H1z" /> 18 - <path fill="#00a4ef" d="M12 1h10v10H12z" /> 19 - <path fill="#7fba00" d="M1 12h10v10H1z" /> 20 - <path fill="#ffb900" d="M12 12h10v10H12z" /> 21 - </svg> 22 - ) 23 - } 24 - 25 - if (providerKey === 'google') { 26 - return ( 27 - <svg viewBox="0 0 24 24" className={className} aria-hidden="true"> 28 - <path 29 - d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" 30 - fill="#4285F4" 31 - /> 32 - <path 33 - d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" 34 - fill="#34A853" 35 - /> 36 - <path 37 - d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" 38 - fill="#FBBC05" 39 - /> 40 - <path 41 - d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" 42 - fill="#EA4335" 43 - /> 44 - <path d="M1 1h22v22H1z" fill="none" /> 45 - </svg> 46 - ) 47 - } 48 - 49 - return ( 50 - <svg viewBox="0 0 24 24" className={className} aria-hidden="true"> 51 - <path 52 - d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" 53 - fill="currentColor" 54 - /> 55 - </svg> 56 - ) 57 - }
+1 -1
components/auth/reset-form.tsx
··· 15 15 } from '@/components/ui/card' 16 16 import { Input } from '@/components/ui/input' 17 17 import { Label } from '@/components/ui/label' 18 - import { authClient } from '@/lib/auth-client' 18 + import { authClient } from '@/lib/auth/client' 19 19 import { cn } from '@/lib/utils' 20 20 21 21 export function ResetPasswordForm({
+1 -1
components/auth/sign-up-form.tsx
··· 15 15 import { Input } from '@/components/ui/input' 16 16 import { InputOTP } from '@/components/ui/input-otp' 17 17 import { Label } from '@/components/ui/label' 18 - import { authClient } from '@/lib/auth-client' 18 + import { authClient } from '@/lib/auth/client' 19 19 import { cn } from '@/lib/utils' 20 20 21 21 export function SignUpForm({
+1 -1
components/landing/navigation.tsx
··· 4 4 import { useState, useEffect } from 'react' 5 5 import { Button } from '@/components/ui/button' 6 6 import { Menu, X } from 'lucide-react' 7 - import { authClient } from '@/lib/auth-client' 7 + import { authClient } from '@/lib/auth/client' 8 8 9 9 const navLinks = [ 10 10 { name: 'Features', href: '#features' },
lib/auth-client.ts lib/auth/client.ts
+1 -1
lib/auth-email-template.tsx lib/auth/email-template.tsx
··· 10 10 Preview, 11 11 Section, 12 12 Text, 13 - render 13 + render, 14 14 } from 'react-email' 15 15 16 16 interface AuthEmailTemplateProps {
lib/auth-server.ts lib/auth/server.ts
+24 -25
lib/auth.ts
··· 4 4 import { betterAuth } from 'better-auth' 5 5 import { emailOTP, twoFactor } from 'better-auth/plugins' 6 6 import { sentinel } from '@better-auth/infra' 7 - import { APP_CONFIG } from '@/lib/config' 8 - import { Resend } from 'resend' 9 7 import bcrypt from 'bcryptjs' 10 - import { renderAuthEmailTemplate } from '@/lib/auth-email-template' 11 - 12 - const resendKey = process.env.RESEND_API_KEY 13 - const resend = resendKey ? new Resend(resendKey) : null 14 - 15 - async function sendAuthEmail(payload: { 16 - to: string 17 - subject: string 18 - html: string 19 - }) { 20 - if (!resend) { 21 - throw new Error('RESEND_API_KEY is not configured') 22 - } 23 - const result = await resend.emails.send({ 24 - from: APP_CONFIG.auth.resend.sender, 25 - to: [payload.to], 26 - subject: payload.subject, 27 - html: payload.html, 28 - }) 29 - if (result.error) throw new Error(result.error.message) 30 - if (!result.data?.id) 31 - throw new Error('Email provider did not return a message id') 32 - } 8 + import { renderAuthEmailTemplate } from '@/lib/auth/email-template' 9 + import { sendAuthEmail } from '@/lib/auth/send-auth-email' 33 10 34 11 export const auth = betterAuth({ 35 12 database: drizzleAdapter(db, { ··· 83 60 body: 'Confirm your email address to finish setting up your account.', 84 61 actionLabel: 'Verify email', 85 62 actionUrl: url, 63 + }), 64 + }) 65 + }, 66 + sendChangeEmailVerification: async ({ 67 + user, 68 + newEmail, 69 + url, 70 + }: { 71 + user: { email: string } 72 + newEmail: string 73 + url: string 74 + }) => { 75 + await sendAuthEmail({ 76 + to: newEmail, 77 + subject: 'Confirm your new email', 78 + html: await renderAuthEmailTemplate({ 79 + preview: 'Confirm your One Calendar email change', 80 + title: 'Confirm your new email', 81 + body: `A request was made to change your account email from ${user.email} to ${newEmail}.`, 82 + actionLabel: 'Confirm email change', 83 + actionUrl: url, 84 + secondary: 'If this was not you, you can ignore this email.', 86 85 }), 87 86 }) 88 87 },
+27
lib/auth/send-auth-email.ts
··· 1 + import { Resend } from 'resend' 2 + import { APP_CONFIG } from '@/lib/config' 3 + 4 + const resendKey = process.env.RESEND_API_KEY 5 + const resend = resendKey ? new Resend(resendKey) : null 6 + 7 + export async function sendAuthEmail(payload: { 8 + to: string 9 + subject: string 10 + html: string 11 + }) { 12 + if (!resend) { 13 + throw new Error('RESEND_API_KEY is not configured') 14 + } 15 + 16 + const result = await resend.emails.send({ 17 + from: APP_CONFIG.auth.resend.sender, 18 + to: [payload.to], 19 + subject: payload.subject, 20 + html: payload.html, 21 + }) 22 + 23 + if (result.error) throw new Error(result.error.message) 24 + if (!result.data?.id) { 25 + throw new Error('Email provider did not return a message id') 26 + } 27 + }
-31
lib/clerk-oauth.ts
··· 1 - import { APP_CONFIG } from '@/lib/config' 2 - 3 - export const OAUTH_PROVIDER_CONFIG = { 4 - microsoft: { 5 - strategy: 'oauth_microsoft', 6 - label: 'Microsoft', 7 - }, 8 - google: { 9 - strategy: 'oauth_google', 10 - label: 'Google', 11 - }, 12 - github: { 13 - strategy: 'oauth_github', 14 - label: 'GitHub', 15 - }, 16 - } as const 17 - 18 - export type OAuthProviderKey = keyof typeof OAUTH_PROVIDER_CONFIG 19 - export type OAuthStrategy = 20 - (typeof OAUTH_PROVIDER_CONFIG)[OAuthProviderKey]['strategy'] 21 - 22 - export function getEnabledOAuthProviderKeys(): OAuthProviderKey[] { 23 - const providers = APP_CONFIG.auth.enabledOAuthProviders as readonly string[] 24 - const parsed = providers 25 - .map((provider) => provider.trim().toLowerCase()) 26 - .filter( 27 - (provider): provider is OAuthProviderKey => 28 - provider in OAUTH_PROVIDER_CONFIG, 29 - ) 30 - return [...new Set(parsed)] 31 - }
+78 -14
lib/crypto.ts
··· 3 3 iv: string 4 4 } 5 5 6 + type KeyCacheMap = Map<string, Promise<CryptoKey>> 7 + const MAX_CACHE_ENTRIES = 128 8 + 6 9 function b64(u: Uint8Array) { 7 10 return btoa(String.fromCharCode(...u)) 8 11 } ··· 15 18 ) 16 19 } 17 20 18 - async function derive(password: string, salt: Uint8Array) { 19 - const k = await crypto.subtle.importKey( 21 + const passwordBaseKeyCache: KeyCacheMap = new Map() 22 + const derivedKeyCache: KeyCacheMap = new Map() 23 + 24 + function setWithLimit( 25 + cache: KeyCacheMap, 26 + key: string, 27 + value: Promise<CryptoKey>, 28 + ) { 29 + if (!cache.has(key) && cache.size >= MAX_CACHE_ENTRIES) { 30 + const oldestKey = cache.keys().next().value 31 + if (oldestKey) cache.delete(oldestKey) 32 + } 33 + cache.set(key, value) 34 + } 35 + 36 + export function clearCryptoKeyCaches() { 37 + passwordBaseKeyCache.clear() 38 + derivedKeyCache.clear() 39 + } 40 + 41 + async function getPasswordBaseKey( 42 + password: string, 43 + cache = passwordBaseKeyCache, 44 + ) { 45 + const cached = cache.get(password) 46 + if (cached) return cached 47 + 48 + const baseKey = crypto.subtle.importKey( 20 49 'raw', 21 50 new TextEncoder().encode(password), 22 51 'PBKDF2', 23 52 false, 24 53 ['deriveKey'], 25 54 ) 26 - return crypto.subtle.deriveKey( 55 + setWithLimit(cache, password, baseKey) 56 + return baseKey 57 + } 58 + 59 + export async function deriveCryptoKey( 60 + password: string, 61 + salt: Uint8Array, 62 + cache = derivedKeyCache, 63 + ) { 64 + const saltArray = new Uint8Array(salt) 65 + const cacheKey = `${password}:${b64(saltArray)}` 66 + const cached = cache.get(cacheKey) 67 + if (cached) return cached 68 + 69 + const baseKey = await getPasswordBaseKey(password) 70 + const derivedKey = crypto.subtle.deriveKey( 27 71 { 28 72 name: 'PBKDF2', 29 - salt: new Uint8Array(salt), 73 + salt: saltArray, 30 74 iterations: 250000, 31 75 hash: 'SHA-256', 32 76 }, 33 - k, 77 + baseKey, 34 78 { name: 'AES-GCM', length: 256 }, 35 79 false, 36 80 ['encrypt', 'decrypt'], 37 81 ) 82 + 83 + setWithLimit(cache, cacheKey, derivedKey) 84 + return derivedKey 85 + } 86 + 87 + function parseCiphertext(ciphertext: string): { salt: string; ct: string } { 88 + const parsed = JSON.parse(ciphertext) 89 + if (typeof parsed?.salt !== 'string' || typeof parsed?.ct !== 'string') { 90 + throw new Error('Invalid encrypted payload format') 91 + } 92 + return parsed 93 + } 94 + 95 + export async function decryptWithDerivedKey( 96 + password: string, 97 + ciphertext: string, 98 + iv: string, 99 + cache?: KeyCacheMap, 100 + ) { 101 + const { salt, ct } = parseCiphertext(ciphertext) 102 + const key = await deriveCryptoKey(password, ub64(salt), cache) 103 + const pt = await crypto.subtle.decrypt( 104 + { name: 'AES-GCM', iv: ub64(iv) }, 105 + key, 106 + ub64(ct), 107 + ) 108 + return new TextDecoder().decode(pt) 38 109 } 39 110 40 111 export async function encryptPayload( ··· 43 114 ): Promise<EncryptedPayload> { 44 115 const salt = crypto.getRandomValues(new Uint8Array(16)) 45 116 const iv = crypto.getRandomValues(new Uint8Array(12)) 46 - const key = await derive(password, salt) 117 + const key = await deriveCryptoKey(password, salt) 47 118 const ct = await crypto.subtle.encrypt( 48 119 { name: 'AES-GCM', iv }, 49 120 key, ··· 64 135 ciphertext: string, 65 136 iv: string, 66 137 ) { 67 - const d = JSON.parse(ciphertext) 68 - const key = await derive(password, ub64(d.salt)) 69 - const pt = await crypto.subtle.decrypt( 70 - { name: 'AES-GCM', iv: ub64(iv) }, 71 - key, 72 - ub64(d.ct), 73 - ) 74 - return new TextDecoder().decode(pt) 138 + return decryptWithDerivedKey(password, ciphertext, iv) 75 139 } 76 140 77 141 export function isEncryptedPayload(value: unknown): value is EncryptedPayload {
-97
lib/dpop.ts
··· 1 - import { 2 - createHash, 3 - createPrivateKey, 4 - generateKeyPairSync, 5 - randomUUID, 6 - sign, 7 - } from 'crypto' 8 - 9 - export interface DpopPublicJwk { 10 - kty: string 11 - crv: string 12 - x: string 13 - y: string 14 - } 15 - 16 - function toBase64Url(input: Buffer | string) { 17 - return Buffer.from(input).toString('base64url') 18 - } 19 - 20 - function jwkThumbprint(publicJwk: DpopPublicJwk) { 21 - const canonical = JSON.stringify({ 22 - crv: publicJwk.crv, 23 - kty: publicJwk.kty, 24 - x: publicJwk.x, 25 - y: publicJwk.y, 26 - }) 27 - 28 - return createHash('sha256').update(canonical, 'utf8').digest('base64url') 29 - } 30 - 31 - export function generateDpopKeyMaterial() { 32 - const { privateKey, publicKey } = generateKeyPairSync('ec', { 33 - namedCurve: 'P-256', 34 - }) 35 - const publicJwk = publicKey.export({ format: 'jwk' }) as DpopPublicJwk 36 - const privateKeyPem = privateKey 37 - .export({ type: 'pkcs8', format: 'pem' }) 38 - .toString() 39 - 40 - if (!publicJwk?.kty || !publicJwk?.crv || !publicJwk?.x || !publicJwk?.y) { 41 - throw new Error('Failed to generate DPoP key material') 42 - } 43 - 44 - return { 45 - publicJwk, 46 - privateKeyPem, 47 - jkt: jwkThumbprint(publicJwk), 48 - } 49 - } 50 - 51 - export function createDpopProof(params: { 52 - htu: string 53 - htm: string 54 - privateKeyPem: string 55 - publicJwk: DpopPublicJwk 56 - accessToken?: string 57 - nonce?: string 58 - }) { 59 - const header = { 60 - typ: 'dpop+jwt', 61 - alg: 'ES256', 62 - jwk: { 63 - kty: params.publicJwk.kty, 64 - crv: params.publicJwk.crv, 65 - x: params.publicJwk.x, 66 - y: params.publicJwk.y, 67 - }, 68 - } 69 - 70 - const payload: Record<string, string | number> = { 71 - jti: randomUUID(), 72 - iat: Math.floor(Date.now() / 1000), 73 - htm: params.htm.toUpperCase(), 74 - htu: params.htu, 75 - } 76 - 77 - if (params.accessToken) { 78 - payload.ath = createHash('sha256') 79 - .update(params.accessToken, 'utf8') 80 - .digest('base64url') 81 - } 82 - 83 - if (params.nonce) { 84 - payload.nonce = params.nonce 85 - } 86 - 87 - const encodedHeader = toBase64Url(JSON.stringify(header)) 88 - const encodedPayload = toBase64Url(JSON.stringify(payload)) 89 - const signingInput = `${encodedHeader}.${encodedPayload}` 90 - 91 - const signature = sign('sha256', Buffer.from(signingInput, 'utf8'), { 92 - key: createPrivateKey(params.privateKeyPem), 93 - dsaEncoding: 'ieee-p1363', 94 - }) 95 - 96 - return `${signingInput}.${toBase64Url(signature)}` 97 - }