@ngriffin_uk/auth-core
v0.5.1
Published
Runtime-neutral authentication context and storage contracts
Readme
@ngriffin_uk/auth-core
Backend configuration, sessions, opaque challenges, and typed authentication middleware. The package has no database, cookie, or framework dependency.
pnpm add @ngriffin_uk/auth-coreimport { createAuth } from "@ngriffin_uk/auth-core";
const auth = createAuth({
users,
sessions,
challenges,
identities,
sessionTtlMs: 30 * 24 * 60 * 60 * 1000,
});
const configured = auth.use(providerMiddleware);This is for the basic implementation only, see the below section for more information on how to implement the stores and middleware.
Implement the stores
auth-core owns the authentication flow, but not persistence. Adapt your
database repositories to its store interfaces:
import {
createEncryptedChallengeStore,
createAuth,
type AuthUser,
type ChallengeStore,
type IdentityStore,
type SessionStore,
type UserStore,
} from "@ngriffin_uk/auth-core";
interface AppUser extends AuthUser {
readonly displayName: string;
}
const users: UserStore<AppUser> = {
findById: (userId) => userRepository.findById(userId),
};
const sessions: SessionStore = {
create: (record) => sessionRepository.insert(record),
findByTokenHash: (tokenHash) =>
sessionRepository.findByTokenHash(tokenHash),
deleteByTokenHash: (tokenHash) =>
sessionRepository.deleteByTokenHash(tokenHash),
rotateByTokenHash: (tokenHash, replacement) =>
sessionRepository.rotateByTokenHash(tokenHash, replacement),
touchByTokenHash: (tokenHash, expiresAt) =>
sessionRepository.touchByTokenHash(tokenHash, expiresAt),
deleteByUserId: (userId) =>
sessionRepository.deleteByUserId(userId),
};
const persistedChallenges: ChallengeStore = {
async create(record) {
await challengeRepository.insert(record);
},
async consumeByTokenHash(tokenHash) {
// This must atomically delete and return the row so a token cannot be reused.
return challengeRepository.takeByTokenHash(tokenHash);
},
};
const challenges = createEncryptedChallengeStore(persistedChallenges, {
secret: env.AUTH_CHALLENGE_SECRET,
previousSecrets: env.PREVIOUS_AUTH_CHALLENGE_SECRET
? [env.PREVIOUS_AUTH_CHALLENGE_SECRET]
: [],
});
const identities: IdentityStore<AppUser> = {
findUser: (provider, providerSubject) =>
identityRepository.findUser(provider, providerSubject),
resolve: (identity) =>
database.transaction(async (transaction) => {
const existing = await transaction.identities.findUser(
identity.provider,
identity.providerSubject,
);
if (existing) return existing;
const user = await transaction.users.createFromIdentity(identity);
await transaction.identities.insert({
provider: identity.provider,
providerSubject: identity.providerSubject,
userId: user.id,
email: identity.email,
emailVerified: identity.emailVerified,
claims: identity.claims,
});
return user;
}),
};
const auth = createAuth({
users,
sessions,
challenges,
identities,
});The repository names above are placeholders for your ORM or database layer. Preserve these storage rules:
- Store
AuthSessionRecord.tokenHashas the session lookup key. The raw token is returned only bycreateSession; send it in a secure cookie and never log or persist it. - Implement
rotateByTokenHashas one atomic consume-and-replace operation.touchByTokenHashmust never recreate a deleted session or shorten its expiry. These lifecycle capabilities fail closed when a store omits them. - Store
AuthChallengeRecord.tokenHashas the continuation lookup key.consumeByTokenHashmust atomically delete and return the record. - Wrap the persistence adapter with
createEncryptedChallengeStore. Use a dedicated service-managed secret containing at least 32 bytes. Plaintext payload rows fail closed; keep old keys only for the lifetime of active challenges during rotation. - Index session and challenge hashes uniquely, index their expiry timestamps, and delete expired records periodically.
- Make
(provider, providerSubject)unique. Do not link accounts by an unverified email address inIdentityStore.resolve.
auth-core hashes raw session and continuation tokens with SHA-256 before it
calls a store. Store adapters must not hash them again.
AuthUser.email is optional so guest, passkey-only, and provider-subject
identities do not need fake email addresses. Email-based plugins require
AuthUserWithEmail explicitly.
