#Authentication
Alepha provides JWT-based authentication through $issuer for token management and $realm for full user management.
#Token Management with $issuer
$issuer is the low-level primitive for creating and verifying JWT tokens. Use it when you manage users yourself or integrate with an external identity provider.
1import { $issuer } from "alepha/security"; 2import { $action } from "alepha/server"; 3import { z } from "alepha"; 4 5class AuthController { 6 issuer = $issuer({ 7 secret: "your-secret-key", 8 }); 9 10 login = $action({11 method: "POST",12 path: "/auth/login",13 schema: {14 body: z.object({15 email: z.email(),16 password: z.text(),17 }),18 },19 handler: async ({ body }) => {20 const user = await this.authenticate(body.email, body.password);21 return this.issuer.createToken(user);22 },23 });24}
#Internal vs External Issuers
An internal issuer signs and verifies tokens with a shared secret:
1issuer = $issuer({2 secret: "my-secret",3});
An external issuer verifies tokens from an external provider (Auth0, Keycloak, etc.) using JWKS:
1issuer = $issuer({2 jwks: () => process.env.AUTH0_JWKS_URL,3 profile: (payload) => ({4 id: payload.sub,5 email: payload.email,6 name: payload.name,7 }),8});
#Token Lifecycle
$issuer manages access tokens and refresh tokens:
| Setting | Default |
|---|---|
| Access token expiration | 15 minutes |
| Refresh token expiration | 30 days |
Override via the settings option:
1issuer = $issuer({2 secret: "...",3 settings: {4 accessToken: { expiration: [1, "hours"] },5 refreshToken: { expiration: [90, "days"] },6 },7});
#User Management with $realm
$realm is a higher-level primitive that wraps $issuer with built-in user management: registration, login, sessions, password handling, and identity providers.
1import { $realm } from "alepha/api/users";2 3class App {4 realm = $realm();5}
$realm ships with two default roles:
- admin: Full access to all resources and permissions.
- user: Access to owned resources only.
#Several realms: a token belongs to the one that minted it
Realms share a signing key by default, so a signature alone does not say which
realm a token came from. What does is its aud claim, which createToken
sets to the issuing realm's name; a resolver refuses a token whose audience
names a different realm.
Each deployed worker must have its own APP_SECRET. A token minted by another
worker then fails signature verification before any claim is read.
That matters when you mint a token by hand. JwtProvider.create's second
argument picks a signing key, not a realm, so in a multi-realm app a token
minted without an audience names nothing and is refused. Set one:
1await jwt.create({ sub: user.id, roles, aud: "staff" }, "staff", {2 header: { typ: jwt.accessTokenTyp },3});
A single-realm app is unaffected, and so is a federated issuer: its aud is
its IdP's client id, and it is protected by holding its own JWKS instead.
#Name the default realm, or pass one
Some lookups name no realm: a permission check given a bare string, a
per-tenant realm name the account carries without a $realm behind it. That
used to resolve to whichever realm was declared FIRST, so the order of fields
in a class decided it - silently, and with real consequences: an app that
declared its staff realm first had verified citizens refused their own
permissions, because their roles were resolved among staff roles.
With one realm there is nothing to be ambiguous about and nothing to declare. From the second realm on, say which one answers:
1class App {2 staff = $realm({ issuer: { name: "staff" } });3 citizens = $realm({ issuer: { name: "citizens", default: true } });4}
Leave it out and a realm-less lookup is refused, naming the realms it could have meant. That is deliberate: a caller that forgot to pass a realm should fail at the call, not resolve against the wrong set.
#Settings That Send a Code Need features.notifications
verifyEmailRequired, verifyPhoneRequired and resetPasswordAllowed each
complete only by delivering a code. Turning one on without
features: { notifications: true } is refused at boot:
1class App {2 // Throws: sets resetPasswordAllowed but features.notifications is off.3 realm = $realm({4 settings: { resetPasswordAllowed: true },5 });6}
The feature flag is all it takes - it registers the notifications module itself, so there is no separate import to remember. What that module then does with the mail (templates, suppression, unsubscribe, delivery receipts) is covered in Notifications and Email.
1import { $realm } from "alepha/api/users";2 3class App {4 realm = $realm({5 features: { notifications: true },6 settings: { resetPasswordAllowed: true },7 });8}
Settings you never mention are unaffected; all three default to false.
#Registration Does Not Confirm Who Has an Account
Registration is deliberately unhelpful about which identifiers are already taken, because a helpful answer is an account-enumeration oracle: post an address, read the error, learn whether that person has an account here.
The behavior depends on verifyEmailRequired:
- Verification on: an address already on file gets the same response a
fresh one gets: an intent id and "check your inbox". No verification code is
minted, so the intent can never be completed. The real owner is emailed a
registrationAttemptnotice instead, which carries no code and asks for no action. - Verification off: a taken username, email or phone all produce one identical error. It never names the field that collided.
Server logs still record which identifier it was, at debug level.
If you present registration errors in your own UI, do not try to map the generic conflict back to a specific field - there is nothing to map it to, and re-deriving it client-side would reopen the hole.
#Letting One Address Into a Closed Realm
settings.registrationAllowed: false closes a realm to everyone. An app that
invites people by email needs a third state: closed, except for the addresses
it has invited. That is isPreAuthorized.
1import { $realm } from "alepha/api/users"; 2 3class Security { 4 realm = $realm({ 5 settings: { registrationAllowed: false }, 6 isPreAuthorized: async ({ email, method, token }) => { 7 // The credentials path has proven nothing yet, so ask for the secret 8 // that arrived in the invitation mail. 9 if (method === "credentials") {10 return token === "the-token-you-mailed-them"11 ? { emailVerified: true }12 : false;13 }14 // The OAuth provider has already proven the address, so a pending15 // invitation for it is enough.16 return email.endsWith("@example.com");17 },18 });19}
It is consulted only when the realm is closed, and at both entry points that can create an account: credentials registration and OAuth first login. A realm that is closed to one and open to the other is not closed.
Three things it deliberately is not:
- Not a rate-limit bypass. The per-IP registration cap runs before the closed check, so a pre-authorized address is throttled like any other.
- Not a captcha bypass.
captchaRequiredis enforced after it. An invitation proves an address, not a human. - Not a different answer. A refusal produces the message a closed realm has always produced, so a prober cannot tell an invited address from any other one.
The credentials path carries preAuthToken on the register request body,
capped at 512 characters and otherwise opaque: the framework never reads it,
validates it or logs it. Returning { emailVerified: true } says the
pre-authorization was itself delivered to that mailbox, so no verification
code is sent or asked for and the account still lands verified. Say that only
when it is true. On the OAuth path the hint is ignored, because there the
provider is the authority.
Leave isPreAuthorized unset and a closed realm behaves exactly as it did
before this existed.
#The First Account Owns a Self-Hosted Instance
An app distributed as an image has a bootstrap problem: someone runs the
container, and there is no administrator yet. bootstrapFirstUser answers it
without a seed command, an ADMIN_PASSWORD in shell history, or a password
sitting in docker inspect.
1import { $realm } from "alepha/api/users";2 3class Security {4 realm = $realm({5 bootstrapFirstUser: true,6 });7}
The operator runs the container, registers, and owns the instance from that moment. Two behaviours, and the second is a guard rather than the main path:
- The first account lands
admin.isPreAuthorizedcannot do this:adminis a persisted role, granted only fromadminEmails/adminUsernames. Both entry points are covered. - A closed realm stays reachable while it holds no account. So an
operator who sets
registrationAllowed: falseon a fresh instance does not brick it, with the only account that could reopen it now uncreatable. It logs at warn whenever it fires, because it loosens a setting that was asked for explicitly.
The account promoted is the oldest row in the realm, which is what makes two concurrent registrations produce exactly one administrator. Promoting on "the table was empty when I looked" produces two; re-counting afterwards and promoting on a count of one produces none, which is worse.
The framework warns at boot while no account exists:
No accounts exist. Registration is open and the first account will become the administrator.
That line is the answer to the window between starting an instance and finishing setup, and it is what Gitea, Grafana and Jellyfin all do. Finish setup before the instance is publicly reachable.
Refused on serverless. A freshly deployed Worker with an empty users table would hand admin to whoever registered first, and the lookup would run on every isolate forever. An app that ships both a Worker and a container gates the option on
!alepha.isServerless()and grants its administrator throughadminEmailson the Worker.
#Identity Providers
Enable login methods through the identities option:
1realm = $realm({2 identities: {3 credentials: true, // email/password (default)4 google: true, // Google OAuth5 github: true, // GitHub OAuth6 },7});
Seven providers are available:
identities key |
Primitive | Protocol | Environment variables |
|---|---|---|---|
credentials |
$authCredentials |
- | none |
google |
$authGoogle |
OIDC | GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET |
github |
$authGithub |
OAuth2 | GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET |
microsoft |
$authMicrosoft |
OIDC | MICROSOFT_CLIENT_ID, MICROSOFT_CLIENT_SECRET, optional MICROSOFT_TENANT_ID |
apple |
$authApple |
OIDC | APPLE_CLIENT_ID, APPLE_CLIENT_SECRET |
facebook |
$authFacebook |
OAuth2 | FACEBOOK_CLIENT_ID, FACEBOOK_CLIENT_SECRET |
franceconnect |
$authFranceConnect |
OIDC | FRANCECONNECT_CLIENT_ID, FRANCECONNECT_CLIENT_SECRET |
A provider whose two variables are not both set is disabled rather than broken: the app boots, the button is absent, and nothing throws. That is what lets one codebase run with Google in production and without it locally.
#Declaring a provider yourself
identities: { google: true } is shorthand. It declares $authGoogle(realm) for
you, and every one of these primitives can be declared directly when you need to
pass options the shorthand does not expose:
1import { $realm } from "alepha/api/users"; 2import { $authGoogle } from "alepha/server/auth"; 3 4class Auth { 5 realm = $realm({ identities: { credentials: true } }); 6 7 google = $authGoogle(this.realm, { 8 scope: 9 "openid profile email https://www.googleapis.com/auth/calendar.readonly",10 authorizationParameters: { prompt: "consent", access_type: "offline" },11 });12}
The first argument is always the realm, which is what supplies the link
function that turns a provider profile into a local account. The second is a
partial OIDC or OAuth2 configuration merged over the defaults.
Three of them have quirks worth knowing before you enable them:
- Apple requires
response_mode=form_postand the scopename email(there is no standardprofilescope), and it sends the user's name only on the very first authorization, as a form field on the POST callback. The framework extracts it into the profile for you; a second login returns nothing butsubandemail. Its client secret is not a string you copy from a console, it is an ES256 JWT you sign from your Apple private key, valid for at most six months. Rotate it before it expires or logins stop. - Microsoft defaults to the
commontenant, which accepts personal, work and school accounts. SetMICROSOFT_TENANT_IDto restrict it. Its discovery document forcommonreturns a literal{tenantid}placeholder as the issuer; that is expected and handled. - France Connect requires
acr_values=eidas1(added automatically) and makes logout mandatory: store theid_tokenfrom login and pass it to the logout endpoint when the session ends.
#$auth, underneath all of them
Every shorthand above returns an $auth. Declare it directly for a provider
that has no shorthand, or for an identity provider that is not yours at all:
1import { $realm } from "alepha/api/users"; 2import { $auth } from "alepha/server/auth"; 3 4class Auth { 5 realm = $realm({ identities: { credentials: true } }); 6 7 gitlab = $auth({ 8 issuer: this.realm, 9 name: "gitlab",10 oidc: {11 issuer: "https://gitlab.com",12 clientId: String(process.env.GITLAB_CLIENT_ID),13 clientSecret: process.env.GITLAB_CLIENT_SECRET,14 },15 });16}
It takes one of three configurations, and which one you pass decides the flow:
| Config | Flow | Use for |
|---|---|---|
credentials |
Client credentials | Your own login form against your own user store |
oauth |
Authorization code, OAuth2 | A provider with no OIDC discovery document |
oidc |
Authorization code, OIDC | Anything that publishes /.well-known |
With issuer set to your realm, Alepha mints the tokens and the external
provider only proves identity. Pass oidc without issuer instead and the
relationship inverts: the external provider (Keycloak, Auth0) issues the tokens
and your app verifies them. That is the AuthExternal shape, and it is the one
to use when an identity server already exists and your app is a relying party
rather than the authority.
OIDC discovery is always lazy. A relying party has to boot when its identity provider is briefly unreachable, so the discovery request happens on the first authentication rather than at startup.
#An OAuth Sign-Up Is Only as Verified as the Provider Says
A first OAuth login creates the local account, and its emailVerified flag
follows the provider's email_verified claim. A provider that sends false
produces an unverified account and the ordinary verification is sent.
A provider that sends no claim has asserted nothing at all.
trustProviderEmail decides what to do with those, and defaults to true, so
the major providers (Google, Microsoft, Apple, GitHub all send the claim) are
unaffected either way. Turn it off for a realm that accepts logins from a
provider where anyone can claim an address they do not own:
1import { $realm } from "alepha/api/users";2 3class App {4 realm = $realm({5 features: { notifications: true },6 // A provider that stays silent about the address is not believed.7 settings: { trustProviderEmail: false },8 });9}
A claim of false is honoured whatever this setting says. It only ever
decides the silent case.
#Self-Service Account Endpoints
alepha/api/users ships the endpoints an account area needs, all under
/users/me. Every one carries a bare $secure() (a session and no permission)
and resolves the row from user.id. None of them takes an id parameter,
which is what makes it safe to leave them un-permissioned: a caller can only
ever ask about themselves. Operators go through the Admin* controllers, which
have their own permissions.
Declared paths are shown; as $actions they serve under the /api prefix
(GET /api/users/me).
| Controller | Endpoints |
|---|---|
MyProfileController |
GET/PATCH /users/me |
MyAvatarController |
POST/DELETE /users/me/avatar |
MyIdentityController |
GET /users/me/identities, POST /users/me/identities/password, DELETE /users/me/identities/:id |
MyPasswordController |
POST /users/me/password |
MySessionController |
GET /users/me/sessions, DELETE /users/me/sessions/:id, POST /users/me/sessions/revoke-others |
MyConnectionController |
GET /users/me/connections, DELETE /users/me/connections/:id |
MyAccountController |
DELETE /users/me |
Two rules are worth knowing before you wire a UI to them:
- Setting a first password and changing one are different endpoints.
setMyFirstPasswordtrusts the session and refuses once acredentialsidentity exists;changeMyPasswordverifies the current password and revokes every other session. Using the first to change a password would make an unattended signed-in browser a full account takeover. - Unlinking the last identity is refused. An account with no sign-in method
is not locked, it is unreachable - and password reset cannot recover it,
because that needs a
credentialsidentity to reset.
@alepha/ui provides the matching UI as AccountRouter - see the
frontend routing guide.
#Deleting an Account: the user:delete:before Hook
deleteMyAccount is a hard delete, and it asks for two independent proofs: the
current password (that it is you) and the account's email typed verbatim
(that you meant it). An OAuth-only account has no password to prove, so the
confirmation stands alone.
The framework only knows about users, identities and sessions. It cannot know
what your application hangs off a user id, so it emits user:delete:before
first and awaits it. A handler that throws aborts the deletion, and the
error reaches the caller unwrapped - with its own status and message.
The hook lives in UserService.deleteUser, which every deletion goes through:
self-service, AdminUserController's single delete, and its bulk delete. One
account, one set of consequences, whoever pressed the button.
1class UserDeletionHook { 2 protected readonly projects = $repository(projects); 3 4 onUserDelete = $hook({ 5 on: "user:delete:before", 6 handler: async ({ userId }) => { 7 const owned = await this.projects.count({ createdBy: { eq: userId } }); 8 if (owned > 0) { 9 throw new ConflictError(`You still own ${owned} project(s).`);10 }11 },12 });13}
Write one if you have foreign keys to
users.id. Without it you are trusting your own cascade rules, and the failure mode is silent: a column with no foreign key leaves orphaned rows pointing at a user that no longer exists, and anonDelete: "cascade"column can delete rows the account authored inside other people's data. Neither is visible in a diff.
#Securing Actions
Actions are public by default. To require authentication, add the $secure() middleware:
1import { $secure } from "alepha/security"; 2 3publicEndpoint = $action({ 4 handler: () => "anyone can access this", 5}); 6 7protectedEndpoint = $action({ 8 use: [$secure()], 9 handler: () => "only authenticated users",10});
The authenticated user is available on the request object:
1profile = $action({2 path: "/me",3 use: [$secure()],4 handler: async ({ user }) => {5 return user;6 },7});
You can also restrict access to a specific issuer or role:
1adminOnly = $action({2 use: [$secure({ issuers: ["admin"] })],3 handler: () => "admin issuer only",4});5 6managersOnly = $action({7 use: [$secure({ roles: ["manager", "admin"] })],8 handler: () => "managers and admins only",9});
#User Resolution
$secure() resolves the authenticated user using atom-first resolution, which works across all transports:
currentUserAtom: checked first. Set by$action.run()fork, MCP transports, pipelines, and jobs.request.user: HTTP request user set by previous middleware.- HTTP headers: JWT or API key resolved from
Authorizationheader. API keys, their expiry, rotation and scope have a guide of their own: API keys.
#Local Action Calls
When calling an action locally via .run(), pass the user in options:
1// Pass a specific user2await controller.action.run({}, { user: { id: "user-1", roles: ["admin"] } });3 4// Use the system user5await controller.action.run({}, { user: "system" });6 7// Use the user from the current HTTP request8await controller.action.run({}, { user: "context" });
The user is scoped to the action call using ALS fork isolation - it does not leak to subsequent calls.
In test mode, .fetch() automatically creates a JWT token from the user option:
1// Automatic test token creation2const res = await controller.action.fetch({}, { user: { id: "test-user" } });
#Roles and Permissions
Define roles with permission sets in the issuer:
1issuer = $issuer({ 2 secret: "...", 3 roles: [ 4 { 5 name: "admin", 6 permissions: [{ name: "*" }], 7 }, 8 { 9 name: "editor",10 permissions: [11 { name: "articles:*" },12 { name: "media:upload" },13 { name: "admin:articles:*" },14 ],15 },16 {17 name: "viewer",18 permissions: [{ name: "articles:list" }, { name: "articles:get" }],19 },20 ],21});
#Wildcard Permissions
Permissions use a colon-separated hierarchy. The * wildcard matches everything at and below its level:
| Pattern | Matches | Does not match |
|---|---|---|
* |
Everything (admin access) | - |
articles:* |
articles:list, articles:get, articles:delete |
media:upload |
admin:articles:* |
admin:articles:list, admin:articles:update |
admin:users:list |
Permissions declared in $secure({ permissions: [...] }) are auto-created in the permission registry at definition time - no separate registration step is needed.
#Ownership
The ownership flag restricts a permission to resources owned by the user:
1{ 2 name: "user", 3 permissions: [ 4 { 5 name: "*", 6 ownership: true, 7 exclude: ["admin:*"], 8 }, 9 ],10}
This grants access to all actions, but only for the user's own resources. The exclude array removes specific permission patterns - here, all admin-namespaced actions are excluded entirely.
#Declaring roles and permissions as primitives
The roles array on $issuer is one way to write this. $role and
$permission from alepha/security are the other: the same registry, declared
where the code they govern lives rather than in one central list.
1import { $permission, $role } from "alepha/security"; 2 3class ArticlePermissions { 4 publish = $permission({ 5 description: "Publish an article to the public site", 6 }); 7 8 editor = $role({ 9 permissions: ["articles:*", "media:upload"],10 });11}
A $permission takes its name from the property key and its group from the
class name, so publish in ArticlePermissions is ArticlePermissions:publish.
Both are overridable with name and group.
The reason to prefer these over the roles array is locality. A permission
declared in the file that enforces it is deleted when that file is, whereas a
central list quietly accumulates permissions for features that no longer exist.
The reason to prefer the array is that it is one place to look. Mixing them is
fine; the registry is shared.
Both primitives can answer questions directly, which is what you want in a handler that has to branch rather than refuse:
1if (this.permissions.publish.can(user)) {2 // show the button3}
$permission.can(user) resolves the user's role names inside their own
realm. That scoping is not incidental: without it, a realm whose admin role
grants nothing would inherit the permissions of whichever homonymous admin
another realm happened to declare first.
$role.can(permission) and $role.check(permission) ask the same question from
the other side, given only a role name.
#The registry as a catalogue
Everything the application ever named is in one registry: $secure() registers
a bare entry for every string it is handed, $permission() adds the rest.
SecurityProvider.permissionCatalogue() reads that registry back as ordered
groups, which is what a screen offering permissions - a role editor, a
per-resource rank matrix - renders itself from.
1import { $permission } from "alepha/security"; 2 3class ArticlePermissions { 4 publish = $permission({ 5 name: "publish", 6 group: "article", 7 label: "permissions.article.publish", 8 groupLabel: "permissions.article.group", 9 groupOrder: 2,10 });11}
label and groupLabel are translation keys, not text: a localised UI
renders the matrix in the reader's language, and description is a plain
string that cannot do that. groupOrder places the section, because
alphabetical is not the order anyone thinks in; a group nobody ordered sorts
after every group somebody did.
A group has no declaration of its own - it exists because permissions name it -
so its label and order are declared on the permissions inside it, and every
permission in one group must agree. Two declarations that disagree are refused
when the second one registers, rather than resolved by load order. For the same
reason, a later declaration enriches an earlier bare one: a $secure that
initialises before its $permission no longer swallows the label.
⚠️ group:name is a stable id. Whatever grants a permission stores that
string, so renaming either half silently re-points every grant that held the old
one, with nothing going red. Never derive an id from a position in an array
either: inserting a permission in the middle then re-maps every stored grant,
and the grants that move are not the ones being edited. Say what a permission is
called with label, which nothing stores and which is free to change.
#issuer on a role, and the ordering trap
A $role with no issuer attaches to every realm, including realms
declared after it. Name one to scope it:
1import { $issuer, $role } from "alepha/security"; 2 3class Security { 4 staff = $issuer({ secret: "change-me" }); 5 6 admin = $role({ 7 issuer: this.staff, 8 permissions: ["*"], 9 });10}
Declaration order matters here, and it is enforced rather than silent. An
$issuer referenced by value only resolves once the container has walked that
property, so a $role written above the $issuer in the same class reads
nothing and would attach to every realm: exactly what naming an issuer was meant
to prevent. That case throws at startup, telling you to declare the $issuer
first or to name its realm as a string.
#$secure Options
$secure() accepts five options. All are optional - when none are provided, it only checks authentication.
1$secure({2 issuers?: string[],3 roles?: string[],4 permissions?: (string | Permission)[],5 sessionOnly?: boolean,6 guard?: (ctx: SecureGuardContext) => Async<boolean>,7})
sessionOnly refuses any identity authenticated by a machine credential (an
API key or an OAuth access token): see
Permission scope and machine credentials.
The guard receives a context object - { user, params, query, body, request?, alepha } - and may be async:
1guard: ({ user, params }) => user.id === params.id;
#Check Order
When multiple options are provided, checks run in this fixed order. Each check must pass before the next runs:
- Authentication: Is there a valid user? →
UnauthorizedError(401) if not. WithsessionOnly, is it a signed-in session rather than a machine credential? →ForbiddenError(403) if not. - Issuers: Does the user's realm match one of the listed issuers? →
ForbiddenError(403) if not. - Roles: Does the user have at least one of the listed roles? →
ForbiddenError(403) if not. - Permissions: Does the user's role grant all listed permissions, and does the credential's
permissionScopeadmit them? →ForbiddenError(403) if not. - Guard: Does the custom function return
true? →ForbiddenError(403) if not.
#AND vs OR Logic
- Issuers: OR: user must match at least one of the listed issuers.
- Roles: OR: user must have at least one of the listed roles.
- Permissions: AND: user must have all listed permissions.
- Options: AND: all provided options must pass.
#Examples
1// Auth only - any authenticated user 2profile = $action({ 3 use: [$secure()], 4 handler: ({ user }) => user, 5}); 6 7// Role check (OR) - admin or manager 8dashboard = $action({ 9 use: [$secure({ roles: ["admin", "manager"] })],10 handler: () => {11 /* ... */12 },13});14 15// Permission check (AND) - must have both16publish = $action({17 use: [$secure({ permissions: ["articles:create", "articles:publish"] })],18 handler: () => {19 /* ... */20 },21});22 23// Issuer restriction24adminPanel = $action({25 use: [$secure({ issuers: ["admin"] })],26 handler: () => {27 /* ... */28 },29});30 31// Custom guard - runs after all other checks32ownProfile = $action({33 use: [$secure({ guard: ({ user, params }) => user.id === params.id })],34 handler: () => {35 /* ... */36 },37});38 39// Combining options - all must pass40adminManage = $action({41 use: [42 $secure({43 issuers: ["main"],44 roles: ["admin"],45 permissions: ["admin:manage"],46 guard: ({ user }) => !!user.email,47 }),48 ],49 handler: () => {50 /* ... */51 },52});
#Browser Behavior
On the server, $secure throws errors (401/403). In the browser, it returns undefined instead - the handler is never called. On $client virtual actions, can() checks authorization without calling:
1// Browser: returns undefined if unauthorized, "ok" if authorized2const result = await action();3 4// $client actions expose can() to check without calling5if (client.myAction.can()) {6 // render the button7}
#Permission scope and machine credentials
Two things an identity can carry, besides its roles, change what $secure and
SecurityProvider answer for it.
#permissionScope: below the roles, never above
permissionScope caps what a credential may do, below what its roles grant:
undefinedis unrestricted. Every signed-in session is on this branch, so nothing changes for an identity that never had a scope.- An array admits a permission only when one of its entries matches it:
a permission name, or a pattern such as
project:*or*. []matches nothing, so every permission-checked route refuses. It reads like a no-op and is a denial.
The scope is applied wherever a permission is read, not only in $secure:
checkUserPermission, getPermissions(user) (so a scoped caller is never
shown actions it cannot call, in /api/_links or elsewhere) and
permissionCatalogueFor(user) all intersect with it.
⚠️ A scope binds permission-checked routes only. A route that declares no
permissions (a bare $secure(), or $secure({ roles })) admits a scoped
credential whatever its scope says. Declare a permission on a route a scope is
meant to keep a credential out of.
Where a scope comes from:
- An API key created with
permissionscarries them as its scope. See API keys. - An OAuth access token carries the permissions its granted scopes declare, as below.
#credential: a machine credential is not a session
An identity authenticated by an API key or by a connected app's OAuth access
token carries a credential marker ({ type: "api-key" | "oauth", id });
a signed-in session carries none. SecurityProvider.isMachineCredential(user)
reads it, and $secure({ sessionOnly: true }) refuses it with a 403.
Declare sessionOnly on every route that mints or revokes credentials,
approves an OAuth grant, or changes the account. The framework does so on the
API key routes that create, rotate and revoke, on OAuth consent and device
approval, and on every non-GET route under /users/me. A machine credential
still reads, and still calls the permission-checked actions its roles and
scope allow.
#OAuth scopes declare what a token reaches
When the realm runs the OAuth authorization server, each scope an app offers is
declared in oauthOptions.scopes with the sentence the consent screen shows,
and the permissions a token granted it may use:
1import { oauthOptions } from "alepha/api/oauth"; 2 3alepha.set(oauthOptions, { 4 scopes: { 5 openid: { label: "Who you are", permissions: [] }, 6 projects: { 7 label: "Your projects", 8 description: "Read and manage the projects you are a member of.", 9 permissions: ["project:*", "quest:*"],10 },11 },12});
- A grant reaches the union of its scopes'
permissions, which becomes the access token'spermissionScope, resolved again at every refresh. permissions: []reaches nothing, which is right for an identity scope.- A scope declared without
permissionsleaves the whole grant unrestricted, as every grant was before scopes narrowed anything, and the server logs a warning at boot for each one. Declare them. - Once any scope is declared, everything else fails closed. Dynamic
registration refuses a scope nobody declared (
invalid_client_metadata), an authorization request whose scopes the client holds none of is sent back witherror=invalid_scope, and a device flow must name at least one declared scope (a registered client, one of its own). A stored grant naming an undeclared scope, or none, reaches no permission-checked route. An app that declares no scope at all keeps granting unrestricted tokens.
#HTTP Basic Auth
$basicAuth provides HTTP Basic Authentication for simple use cases (webhooks, internal tools):
1import { $env, z } from "alepha"; 2import { $basicAuth } from "alepha/security"; 3 4class WebhookController { 5 protected readonly env = $env(z.object({ WEBHOOK_SECRET: z.text() })); 6 7 webhook = $action({ 8 use: [ 9 $basicAuth({ username: "stripe", password: this.env.WEBHOOK_SECRET }),10 ],11 handler: ({ body }) => {12 /* ... */13 },14 });15}
Uses timing-safe comparison to prevent timing attacks. Returns 401 with WWW-Authenticate header on failure.
An empty password is refused when the middleware is declared, so an app whose secret is unset fails to start rather than letting in anyone who sends the username with no password. Declare the variable as required, as above, rather than reading an optional one as ?? "".
To put a whole path family behind the same credentials (pages and static files included, not only actions), hand it to $middleware:
1import { $env, z } from "alepha"; 2import { $basicAuth } from "alepha/security"; 3import { $middleware } from "alepha/server"; 4 5class SiteAuth { 6 protected readonly env = $env(z.object({ SITE_PASSWORD: z.text() })); 7 8 site = $middleware({ 9 path: "/",10 exclude: ["/health"],11 use: [$basicAuth({ username: "admin", password: this.env.SITE_PASSWORD })],12 });13}
#Service Accounts
$serviceAccount manages tokens for service-to-service communication:
1import { $env, z } from "alepha"; 2import { $serviceAccount } from "alepha/security"; 3 4env = $env(z.object({ CLIENT_ID: z.text(), CLIENT_SECRET: z.text() })); 5 6// OAuth2 client credentials 7external = $serviceAccount({ 8 oauth2: { 9 url: "https://provider.com/oauth2/token",10 clientId: this.env.CLIENT_ID,11 clientSecret: this.env.CLIENT_SECRET,12 },13});14 15// JWT-based (internal issuer)16internal = $serviceAccount({17 issuer: myIssuer,18 user: { id: "batch-worker" },19});20 21// Usage: tokens are cached and auto-refreshed22const token = await external.token();