@sneekin/auth
v4.0.0
Published
One Sneek client for passwordless authentication and customer messages
Readme
@sneekin/auth
One server-side package for passwordless customer authentication and customer messages. Install it once and use the same application API key for both.
npm install @sneekin/authOne client
import { Sneek } from '@sneekin/auth';
const sneek = new Sneek({
apiUrl: process.env.SNEEK_API_URL ?? 'https://sneek.in',
apiKey: process.env.SNEEK_API_KEY!,
});
// Sneek owns the challenge, the channel choice, and verification.
const request = await sneek.auth.requestVerification({ identifier });
const result = await sneek.auth.checkVerification({
id: request.id,
code,
});
// result: { status: 'verified', idToken, subject, claims } -- claims is the
// decoded, signature-verified ID token. Mint your own session from `claims`;
// Sneek never holds a session for you to introspect.
// Send an OTP generated by your application or any generic message.
await sneek.sendOTP({ to: mobile, code: '482917', appName: 'Acme' });
await sneek.messages.send({
to: mobile,
body: 'Your order has shipped.',
type: 'transactional',
});NestJS
There is no SneekAuthGuard (removed in v3) — Sneek never issues you a session
to introspect on every request. Verify an identifier once, get back an ID
token, and mint your own session from its claims; authenticating your app's
subsequent requests is your own session mechanism, not Sneek's concern.
import { SneekAuthClient, SneekAuthModule } from '@sneekin/auth';
@Module({
imports: [
SneekAuthModule.forRoot({
apiUrl: process.env.SNEEK_API_URL ?? 'https://sneek.in',
apiKey: process.env.SNEEK_API_KEY!,
}),
],
})
export class AuthModule {}Complete browser login
Render SneekLogin from @sneekin/ui with
endpoint="/api/auth/sneek". Import SneekLoginModule in the backend; do not
add application QR controllers, registration tables, transport hooks, or login
wrappers. The package facade owns browser binding and the QR strategy. The
application owns its users, authorization, and session creation only.
import { Module } from '@nestjs/common';
import { SneekAuthClient, SneekAuthModule, SneekLoginModule } from '@sneekin/auth';
const clientModule = SneekAuthModule.forRoot({
apiUrl: process.env.SNEEK_API_URL!,
apiKey: process.env.SNEEK_API_KEY!,
});
@Module({
imports: [
SneekLoginModule.forRootAsync({
imports: [clientModule, SessionServicesModule],
inject: [SneekAuthClient, SessionService],
useFactory: (client: SneekAuthClient, sessions: SessionService) => ({
client,
origin: process.env.PUBLIC_URL!,
secret: process.env.SNEEK_LOGIN_SECRET!,
onVerified: (result) => sessions.signInWithSneek(result),
}),
}),
],
})
export class LoginModule {}SessionService and SessionServicesModule above are application services:
export the service from that module, without importing LoginModule back into
it. The async factory supports Nest imports, injection tokens, and optional
factory dependencies. SneekLoginOptions is exactly:
interface SneekLoginOptions {
client: SneekAuthClient;
origin: string;
secret: string;
beforeRequest?: (identifier: string) => Promise<void>;
onVerified: (result: VerifiedCheckResult) => Promise<unknown>;
}`beforeRequest` is an optional application eligibility check. It receives the
trimmed identifier and is awaited before `client.requestVerification` for
identifier starts only. It does not choose or change channels and does not run
for QR creation, status, switching, or completion. Deny ineligible identifiers
with `SneekLoginError(403, 'access_denied', 'Staff only.')` to prevent a provider
verification request. Unexpected callback failures are sanitized to 503.
`onVerified` receives only `verified` results. The package handles
`profile_required` before this callback, returning a safe HTTP 428 recovery
response with a validated enrollment URL. The application must check local
authorization before issuing its session, including for QR identities that
never pass through `beforeRequest`. Its return value becomes the successful
response body; an authorization denial can throw the same `SneekLoginError`.
Since provider redemption is one-time, a callback failure requires a new
verification; neither redemption nor the callback is retried.
The default controller is the exported `SneekLoginController` at `auth/sneek`
with one `@Post()` action. `SneekLoginModule.forRootAsync({ path:
'admin/auth/sneek', imports, inject, useFactory })` creates a unique controller
at a bootstrap-configured path using the same validated handler. Public and
console modules can coexist with separate exact origins, callbacks, and
origin-bound cookies/proofs, even when they share a client and signing secret.
The path is not an incoming request field or an option returned by `useFactory`.
Set the consumer's Nest global prefix to `api`. Make these routes anonymous under theconsumer's session guard policy; the package still enforces Origin and proofs.
No Fastify plugin is needed for binding cookies. The exported
SneekLoginHandler.handle(request, reply) uses structural headers/body and
header/code/send types, with no Fastify dependency.
Action contract
Every request is same-origin JSON POST to /api/auth/sneek, with credentials.
Unknown fields/actions, absent or mismatched Origin, malformed IDs, and
oversized inputs are rejected. No action accepts an application API key.
| Request | Response (HTTP 200) |
| --- | --- |
| {action:'config'} | {applicationId, origin, enabled} plus a binding cookie if needed |
| {action:'create'} | {ref, nonce, issuedAt, expiresAt, bindingProof} |
| {action:'status', ref, bindingProof} | {status:'pending'} or {status:'approved', requestId} |
| {action:'verify', requestId, ref, bindingProof} | Application callback result after matching approved QR redemption |
| {action:'request', identifier} | {requestId, channel, maskedIdentifier, expiresAt, alternatives, challengeNumber?, proof} |
| {action:'verify', requestId, code?, proof} | Application callback result after owned OTP/PUSH verification |
| {action:'switch', requestId, channel, proof} | Replacement request response with a new proof |
QR issuedAt is Unix seconds and expiresAt is Unix milliseconds; request expiresAt is an ISO timestamp capped
to its proof's expiry. QR references are
SHA256(Buffer.from(nonce, 'hex')), not the hash of the hex text. Only the
server mints the 32-byte nonce and signed registration; no caller-provided
nonce/reference registration exists. QR lookup uses the application-key-scoped
partner approval endpoint and must match requestId before redemption.
Identifier verification supports sms, whatsapp, email, and push, never
QR through an OTP proof. Sneek chooses the initial channel. Switching requires
both the signed and current provider-offered alternatives. OTP requires a
4-12 digit string; PUSH requires out-of-band approval and no code. An
unapproved owned request returns 425. enabled: false disables QR only;
identifier requests still work and can establish their own binding cookie.
Errors are {code, message} with HTTP 400 (input/code), 401 (expired or
consumed), 403 (origin/binding/channel), 425 (pending), 428 (profile required),
429 (provider rate limit), or 503 (unavailable). Provider internals are never
forwarded. Expired/revoked/consumed QR approvals are indistinguishable from
pending at the provider lookup; the local registration expires within 300s.
Security and lifecycle
- Keep the API key and a cryptographically random secret of at least 32 bytes server-only. Use the same secret on every replica; rotating it invalidates all browser bindings and proofs.
- Configure an exact HTTPS origin, without path/trailing slash/userinfo. HTTP is accepted only on loopback development hosts. Native requests without Origin must use the application's separate native endpoint, not this one.
- The host-only
__Host-sneek_logincookie is HttpOnly, Secure, SameSite=Strict, Path=/, and expires after one day. Loopback HTTP usessneek_loginwithout Secure. A valid binding is preserved across tabs and QR refreshes; it is not rotated or cleared after a single verification. - HS256
joseproofs bind purpose, browser HMAC, application, origin, current canonical configuration, issue time, and expiry. Their lifetime is at most 300 seconds and never exceeds the cookie or provider request expiry. Config changes invalidate existing proofs; a new request is required. - There is no package user-state cache, database, subscription, or background timer to clean up. Dropping a proof cancels that browser's attempt locally; server-side proof revocation before expiry is not provided. Sneek enforces approval expiry/cancellation and atomic one-time consumption.
- Apply the consumer's normal ingress body-size/IP rate limits. Inputs and crypto work are bounded here, and provider request rate limits are preserved; this module does not introduce an in-memory per-user limiter.
Native OTP endpoint
For an existing application native code endpoint, call the package method directly:
const result = await sneekAuth.checkCodeVerification({ id: requestId, code });CheckCodeVerificationInput has id: string, code: string, and optional
channel: 'sms' | 'whatsapp' | 'email'. The method polls first and rejects
wrong IDs, non-OTP or mismatched channels, terminal requests, and expiry before
consuming. It cannot be used without a code. The lower-level
checkVerification also supports QR/PUSH and must not be exposed as an
unbound public browser verifier.
getQrConfig(): Promise<SneekQrConfig> reads
GET /api/verify/qr/partner-config
({applicationId, clientId, issuer, origins, enabled}).
getQrApproval(ref): Promise<SneekQrApproval> reads
GET /api/verify/qr/approvals/:ref (pending or approved plus requestId).
Both keep the API key on the server.
ID token validation
checkVerification and checkCodeVerification always verify the ES256
signature against the configured API's /oauth/jwks, require iss, aud,
sub, iat, and exp, reject expired tokens, future issue times and empty
subjects, and require the response subject to equal the verified claims.sub.
This applies to OTP, PUSH and QR, including native code verification.
The API-key-authenticated partner config is authoritative: clientId is the
expected audience (not applicationId), and issuer is the actual OIDC
provider issuer derived from PUBLIC_URL. An internal apiUrl can differ
from that public issuer. QR being disabled does not disable config discovery
or identifier verification. Missing issuer/clientId or failed config discovery
fails closed; no expected value is derived from the supplied token.
SneekAuthOptions (also inherited by SneekOptions) accepts optional expectedIssuer: string and
expectedAudience: string. Existing construction with just apiUrl and
apiKey remains valid. For public verifyIdToken(idToken) calls, supplying
both trusted values skips config discovery; supplying neither or only one
fetches the scoped config and checks any supplied value against it. These
options must come from trusted server configuration, never from a token or
request. Completion methods always fetch the API-key-scoped config and reject
conflicting options, so forwarded-token settings cannot override application
scope. Config is fetched on each such verification; JWKS caching remains
managed by jose.
Deploy the provider contract before rolling out these auth 3.1.0 consumers.
Older providers without clientId and issuer will fail closed. Rebuild any
previously created package tarballs from the updated source before integration.
The browser login action response remains {applicationId, origin, enabled};
these server verification fields do not alter the UI contract.
API Contract
POST /api/verify->{ id, status, channel, maskedIdentifier, expiresAt }GET /api/verify/:id-> poll a pending (push/qr) verificationPOST /api/verify/:id/check->{ status: 'verified', idToken, subject }once approved/coded correctly
All endpoints require the app's SNEEK_API_KEY; user access tokens are never
used as app credentials.
