Auth / foldkit-auth
Signing up
foldkit-auth reads sessions; it creates no accounts. Sign-up is Yielded's
password strategy, and the application's own account row is written in the
same commit, by a hook the application supplies. Wiring it to
@yielded/auth@0.1.0-beta.47 takes four pieces.
The contract names the action. AuthContract has a helper for password
sign-in but none for registration, so register is declared with the schemas
of the strategy's own register operation. An action calls the strategy method
of its own name, so the name is register:
import { AuthContract, Password } from '@yielded/auth'
import { Email } from '@yielded/auth/Schema'
import { Schema } from 'effect'
/** What sign-up asks beyond an email and a password: the application's own fields. */
export const Registration = Schema.Struct({ displayName: Schema.NonEmptyString })
export const AuthApi = AuthContract.make('app/Auth', {
claims: Schema.Struct({}),
actions: sessions => ({
signIn: AuthContract.passwordSignIn(sessions),
register: AuthContract.action({
payload: Schema.Struct({
requestId: Password.PasswordCommandId,
email: Email,
newPassword: Schema.RedactedFromValue(Schema.String),
registration: Registration,
}),
success: Schema.TaggedStruct('RegistrationAccepted', {}),
error: Schema.Union([
Password.NewPasswordRejected,
Password.PasswordCheckUnavailable,
Password.PasswordRejected,
Password.PasswordUnavailable,
Password.PasswordMethodUnsupported,
Password.PasswordActionRequired,
]),
mode: 'mutation',
replay: 'idempotent',
}),
}),
})The strategy takes registration only beside reset. Registration
comes with Yielded's password management (add, change, reset), not alone.
Http.layer serves only the actions the contract names, so an application
whose contract names no reset action serves none. It must still configure
one. Password.resetLink({ url }) checks its URL when the layer is built.
The URL must be https:, with no query or fragment, and exactly its own
href, so https://app.example/reset is accepted, while
https://app.example (whose href adds a slash) and http://localhost:5173/reset
fail with ProofConfigurationError { reason: 'delivery' }.
Password.resetCode() takes no URL, but requires Proofs.ProofKeys:
import { Auth, Sessions } from '@yielded/auth'
export const AppAuth = Auth.make(AuthApi, {
sessions: Sessions.stateful({ idleTimeout: '1 hour', maxAge: '1 day' }),
strategies: {
password: Password.make({
registration: Registration,
reset: Password.resetLink({ url: 'https://app.example/reset' }),
}),
},
defaultStrategy: 'password',
})Provisioning writes the account row. AuthPersistence.make(AppAuth)
asks for Provisioning. Its values returns the new subject row, keyed by
the column keys of the subject table given Persistence.Config: the id, the
active status, the first security revision, and the application's own
columns. Yielded inserts that row in the commit that binds the email and
password, so an account never exists without its row. values must not
write or call out; on D1 only this form is accepted (an interactive driver
also takes a callback that inserts the row itself):
import { AuthPersistence } from '@yielded/auth-persistence'
import { Effect, Layer } from 'effect'
const Persistence = AuthPersistence.make(AppAuth)
export const Provisioning = Layer.succeed(Persistence.Provisioning, {
password: {
values: ({ identifier, registration }) =>
Effect.sync(() => ({
id: crypto.randomUUID(),
status: 'active', // the subject table's `activeValue`
securityRevision: crypto.randomUUID(),
email: identifier.value,
displayName: registration.displayName,
})),
},
})Management needs three services that sign-in does not. Yielded gives them no default:
Password.CompromisedPasswordsscreens every new password, so it is asked at each sign-up. Yielded makes no network call of its own and allows nothing by default: check a local list here, or Pwned Passwords' range API. It is given the email too (accountName), to refuse a password that contains it ('contextual').Password.PasswordActionEvidenceandEmailDelivery.EmailDeliveryserve password change and reset. A contract without those actions never asks them, so refusing stubs are enough.
import { EmailDelivery } from '@yielded/auth'
import { Redacted } from 'effect'
const common = new Set(['password password', 'correct horse battery staple'])
export const PasswordOnly = Layer.mergeAll(
Layer.succeed(Password.CompromisedPasswords, {
check: password =>
Effect.succeed(
common.has(Redacted.value(password))
? { _tag: 'Rejected', reason: 'common' }
: { _tag: 'Allowed' },
),
}),
Layer.succeed(Password.PasswordActionEvidence, {
verify: () => Effect.fail(Password.PasswordActionRequired.make({})),
}),
Layer.succeed(EmailDelivery.EmailDelivery, {
send: () => Effect.fail(EmailDelivery.EmailNotAccepted.make({})),
}),
)Provide PasswordOnly to AppAuth.layer, and Provisioning to
Persistence.layer, alongside what sign-in already needs: the persistence
layer and its Config, SessionClaims, and the platform's crypto and
hashing (@yielded/auth-platform-workerd's layer on Workers). Yielded's Testing store
refuses password management when it is built, so a sign-up test needs SQL
persistence, such as SQLite in memory.
The client registers, then signs in. register answers
RegistrationAccepted both when it creates an account and when the email
already has one, so it does not reveal which emails are taken. It does not
sign in either:
import * as Client from '@yielded/auth/Client'
const AppClient = Client.make(AuthApi, { baseUrl: location.origin })
export const signUp = (form: {
readonly requestId: string // one per submission, handed to `values` as `requestId`
readonly email: string
readonly password: Redacted.Redacted<string>
readonly displayName: string
}) =>
Effect.gen(function* () {
const { auth } = yield* AppClient
yield* auth.register({
requestId: form.requestId,
email: form.email,
newPassword: Redacted.value(form.password),
registration: { displayName: form.displayName },
})
return yield* auth.signIn({ email: form.email, password: Redacted.value(form.password) })
})registerfails withNewPasswordRejectedfor a password the policy or the screening refuses, whether the email is taken or not. By default a new password needs 15 code points (NIST SP 800-63B for a single factor), so say so on the form, or supplyPassword.NewPasswordCheck.layer(policy)with anotherminimumCodePoints.- With a taken email, the sign-in after it fails with
PasswordRejected, unless the password is that account's own. - A retried
registercreates nothing twice: by then the email is taken, and the retry is answeredRegistrationAcceptedlike the first. - Call
belief.forget()after the sign-in succeeds, as for any sign-in.
Limits
- MCP agents send a bearer credential, which no resolver here reads yet. A
Sync replica reconnects after a 4401, refused at each upgrade, since a
browser's
WebSocketdoes not say why an upgrade failed. - Cookie sessions only; a bearer credential is not read.
- A session cookie with a
domaintrusts every subdomain, as Yielded documents: one that sets its own cookie of the same name makes every requestSign in again.until it is cleared.