Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieusieben committed Feb 7, 2025
1 parent f90eedc commit bb81208
Show file tree
Hide file tree
Showing 24 changed files with 1,121 additions and 333 deletions.
18 changes: 17 additions & 1 deletion packages/oauth/oauth-provider/src/account/account-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class AccountManager {
deviceId: DeviceId,
): Promise<AccountInfo> {
return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
const result = await this.store.authenticateAccount(credentials, deviceId)
const result = await this.store.authenticateAccount(deviceId, credentials)
if (result) return result

throw new InvalidRequestError('Invalid credentials')
Expand Down Expand Up @@ -52,4 +52,20 @@ export class AccountManager {
const results = await this.store.listDeviceAccounts(deviceId)
return results.filter((result) => result.info.remembered)
}

public async resetPasswordRequest(deviceId: DeviceId, email: string) {
return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
await this.store.resetPasswordRequest(deviceId, email)
})
}

public async resetPasswordConfirm(
deviceId: DeviceId,
token: string,
newPassword: string,
) {
return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
await this.store.resetPasswordConfirm(deviceId, token, newPassword)
})
}
}
37 changes: 24 additions & 13 deletions packages/oauth/oauth-provider/src/account/account-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,21 @@ import { Awaitable } from '../lib/util/type.js'
import { Sub } from '../oidc/sub.js'
import { Account } from './account.js'

export const signInCredentialsSchema = z.object({
username: z.string(),
password: z.string(),
export const signInCredentialsSchema = z
.object({
username: z.string(),
password: z.string(),

/**
* If false, the account must not be returned from
* {@link AccountStore.listDeviceAccounts}. Note that this only makes sense when
* used with a device ID.
*/
remember: z.boolean().optional().default(false),
/**
* If false, the account must not be returned from
* {@link AccountStore.listDeviceAccounts}. Note that this only makes sense when
* used with a device ID.
*/
remember: z.boolean().optional().default(false),

emailOtp: z.string().optional(),
})
emailOtp: z.string().optional(),
})
.strict()

export type SignInCredentials = z.TypeOf<typeof signInCredentialsSchema>

Expand All @@ -37,8 +39,8 @@ export type AccountInfo = {

export interface AccountStore {
authenticateAccount(
credentials: SignInCredentials,
deviceId: DeviceId,
credentials: SignInCredentials,
): Awaitable<AccountInfo | null>

addAuthorizedClient(
Expand All @@ -55,6 +57,13 @@ export interface AccountStore {
* be returned. The others will be ignored.
*/
listDeviceAccounts(deviceId: DeviceId): Awaitable<AccountInfo[]>

resetPasswordRequest(deviceId: DeviceId, email: string): Awaitable<void>
resetPasswordConfirm(
deviceId: DeviceId,
token: string,
password: string,
): Awaitable<void>
}

export function isAccountStore(
Expand All @@ -65,7 +74,9 @@ export function isAccountStore(
typeof implementation.getDeviceAccount === 'function' &&
typeof implementation.addAuthorizedClient === 'function' &&
typeof implementation.listDeviceAccounts === 'function' &&
typeof implementation.removeDeviceAccount === 'function'
typeof implementation.removeDeviceAccount === 'function' &&
typeof implementation.resetPasswordRequest === 'function' &&
typeof implementation.resetPasswordConfirm === 'function'
)
}

Expand Down
12 changes: 10 additions & 2 deletions packages/oauth/oauth-provider/src/assets/app/components/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ export function Button({
role = 'Button',
color = 'grey',
disabled = false,
transparent = false,
loading = undefined,
...props
}: {
color?: 'brand' | 'grey'
loading?: boolean
transparent?: boolean
} & ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
Expand All @@ -23,8 +25,14 @@ export function Button({
className={clsx(
'py-2 px-6 rounded-lg truncate cursor-pointer touch-manipulation tracking-wide overflow-hidden',
color === 'brand'
? 'bg-brand text-white'
: 'bg-slate-100 hover:bg-slate-200 text-slate-600 dark:bg-slate-800 dark:hover:bg-slate-700 dark:text-slate-300',
? transparent
? 'bg-transparent text-brand'
: 'bg-brand text-white'
: clsx(
'text-slate-600 dark:text-slate-300',
'hover:bg-slate-200 dark:hover:bg-slate-700',
transparent ? 'bg-transparent' : 'bg-slate-100 dark:bg-slate-800',
),
className,
)}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { FormEventHandler, ReactNode, useEffect } from 'react'
import { useAsyncAction } from '../hooks/use-async-action'
import { Override } from '../lib/util'
import { Button } from './button'
import { FormCard, FormCardProps } from './form-card'

export type FormCardAsyncProps = Override<
Omit<FormCardProps, 'cancel' | 'actions' | 'error'>,
{
onSubmit: FormEventHandler<HTMLFormElement>
submitLabel?: ReactNode
submitAria?: string

onCancel?: () => void
cancelLabel?: ReactNode
cancelAria?: string

onLoading?: (loading: boolean) => void
onError?: (error: Error | undefined) => void

errorMessageFallback?: ReactNode
errorSlot?: (error: Error) => null | undefined | ReactNode
}
>

export default function FormCardAsync({
onSubmit,
submitAria = 'Submit',
submitLabel = submitAria,

onCancel = undefined,
cancelAria = 'Cancel',
cancelLabel = cancelAria,

errorMessageFallback = 'An unknown error occurred',
errorSlot,
onLoading,
onError,

children,

...props
}: FormCardAsyncProps) {
const { run, loading, error } = useAsyncAction(onSubmit)

useEffect(() => {
onLoading?.(loading)
}, [onLoading, loading])

useEffect(() => {
onError?.(error)
}, [onError, error])

return (
<FormCard
{...props}
onSubmit={run}
error={error ? errorSlot?.(error) || errorMessageFallback : undefined}
cancel={
onCancel && (
<Button aria-label={cancelAria} onClick={onCancel}>
{cancelLabel}
</Button>
)
}
actions={
<Button
color="brand"
type="submit"
aria-label={submitAria}
loading={loading}
>
{submitLabel}
</Button>
}
>
{children}
</FormCard>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { ChangeEvent, forwardRef } from 'react'
import { TokenIcon } from './icons/token-icon'
import { InputText, InputTextProps } from './input-text'

export type InputOtpProps = Omit<
InputTextProps,
| 'type'
| 'pattern'
| 'autoCapitalize'
| 'autoCorrect'
| 'autoComplete'
| 'spellCheck'
| 'minLength'
| 'maxLength'
| 'dir'
> & {
example?: string
onOtp?: (code: string | null) => void
}

export const OTP_CODE_EXAMPLE = 'XXXXX-XXXXX'

export const InputOtp = forwardRef<HTMLInputElement, InputOtpProps>(
(
{
icon = <TokenIcon className="w-5" />,
example = OTP_CODE_EXAMPLE,
title = example,
placeholder = `Looks like ${example}`,
onChange,
onOtp,
...props
},
ref,
) => {
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { value, selectionEnd, selectionStart } = event.currentTarget

const fixedValue = fix(value)

event.currentTarget.value = fixedValue

// Move the cursor back where it was relative to the original value
const pos = selectionEnd ?? selectionStart
if (pos != null) {
const fixedSlicedValue = fix(value.slice(0, pos))
event.currentTarget.selectionStart = event.currentTarget.selectionEnd =
fixedSlicedValue.length
}

onChange?.(event)

if (!event.isDefaultPrevented()) {
onOtp?.(fixedValue.length === 11 ? fixedValue : null)
}
}

return (
<InputText
{...props}
type="text"
autoCapitalize="none"
autoCorrect="off"
autoComplete="off"
spellCheck="false"
minLength={11}
maxLength={11}
dir="auto"
ref={ref}
icon={icon}
pattern="^[A-Z2-7]{5}-[A-Z2-7]{5}$"
placeholder={placeholder}
title={title}
onChange={handleChange}
/>
)
},
)

function fix(value: string) {
const normalized = value.toUpperCase().replaceAll(/[^A-Z2-7]/g, '')

if (normalized.length <= 5) return normalized

return `${normalized.slice(0, 5)}-${normalized.slice(5, 10)}`
}
Loading

0 comments on commit bb81208

Please sign in to comment.