alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
1,043 lines (906 loc) • 30.1 kB
text/typescript
import {
$hook,
$inject,
Alepha,
AppNotStartedError,
ContainerLockedError,
} from "alepha";
import { SecretProvider } from "alepha/crypto";
import { $logger } from "alepha/logger";
import { ForbiddenError } from "alepha/server";
import type { JSONWebKeySet, JWTPayload } from "jose";
import type { JWTVerifyOptions } from "jose/jwt/verify";
import { currentTenantAtom } from "../atoms/currentTenantAtom.ts";
import { currentUserAtom } from "../atoms/currentUserAtom.ts";
import { InvalidPermissionError } from "../errors/InvalidPermissionError.ts";
import { InvalidTokenError } from "../errors/InvalidTokenError.ts";
import { RealmNotFoundError } from "../errors/RealmNotFoundError.ts";
import { SecurityError } from "../errors/SecurityError.ts";
import type { IssuerResolver, UserInfo } from "../interfaces/IssuerResolver.ts";
import type { UserAccountToken } from "../interfaces/UserAccountToken.ts";
import type { Permission } from "../schemas/permissionSchema.ts";
import type { Role } from "../schemas/roleSchema.ts";
import {
type UserAccount,
userAccountInfoSchema,
} from "../schemas/userAccountInfoSchema.ts";
import { JwtProvider, type SigningConfig } from "./JwtProvider.ts";
export class SecurityProvider {
protected readonly UNKNOWN_USER_NAME = "Anonymous User";
protected readonly PERMISSION_REGEXP = /^[\w-]+((:[\w-]+)+)?$/;
protected readonly PERMISSION_REGEXP_WILDCARD =
/^[\w-]+((:[\w-]+)*:\*|(:[\w-]+)+)?$/;
protected readonly log = $logger();
protected readonly jwt = $inject(JwtProvider);
protected readonly alepha = $inject(Alepha);
protected readonly secretProvider = $inject(SecretProvider);
public get secretKey(): string {
return this.secretProvider.secretKey;
}
/**
* The permissions configured for the security provider.
*/
protected readonly permissions: Permission[] = [];
/**
* The realms configured for the security provider.
*/
protected readonly realms: Realm[] = this.alepha.isTest()
? [
{
name: "default",
secret: this.secretKey,
roles: [
{
name: "admin",
permissions: [
{
name: "*",
},
],
},
],
},
]
: [];
protected start = $hook({
on: "start",
handler: async () => {
for (const realm of this.realms) {
if (realm.signing) {
await this.jwt.setSigningKey(realm.name, realm.signing);
} else if (realm.secret) {
const secret =
typeof realm.secret === "function" ? realm.secret() : realm.secret;
this.jwt.setKeyLoader(realm.name, secret);
}
// Register default JWT resolver for realms without resolvers
if (!realm.resolvers || realm.resolvers.length === 0) {
this.registerResolver(
this.createDefaultJwtResolver(realm.name),
realm.name,
);
}
}
},
});
/**
* Creates a default JWT resolver for a realm.
*/
protected createDefaultJwtResolver(realmName: string): IssuerResolver {
return {
priority: 100,
onRequest: async (req) => {
const auth = req.headers.authorization;
if (!auth?.startsWith("Bearer ")) {
return null;
}
const token = auth.slice(7);
// Check if it looks like a JWT (has dots)
if (!token.includes(".")) {
return null;
}
// Parse and validate JWT
const { result } = await this.jwt.parse(token, realmName);
// Reject tokens whose tenant claim doesn't match the tenant resolved
// for the current request. Prevents a token minted on tenant A from
// being replayed on tenant B (subdomain spoofing, leaked bearer
// tokens). Tokens minted without a tenant claim (no active tenant at
// session creation) pass through — single-tenant apps are unaffected.
const claimTenant = this.getTenantFromPayload(result.payload);
if (claimTenant) {
const activeTenant = this.alepha.store.get(currentTenantAtom)?.id;
if (activeTenant && activeTenant !== claimTenant) {
this.log.warn("JWT tenant claim does not match active tenant", {
claim: claimTenant,
active: activeTenant,
});
return null;
}
}
// Extract user info from JWT payload
return this.createUserFromPayload(result.payload, realmName);
},
};
}
/**
* Adds a role to one or more realms.
*
* @param role
* @param realms
*/
public createRole(role: Role, ...realms: string[]): Role {
const list = realms.length
? realms.map((it) => {
const item = this.realms.find((realm) => realm.name === it);
if (!item) {
throw new RealmNotFoundError(it);
}
return item;
})
: this.realms;
for (const realm of list) {
for (const { name } of role.permissions) {
if (this.alepha.isStarted()) {
// Check if permission exists or matches a wildcard pattern
if (name === "*") {
// Global wildcard is always allowed
continue;
}
// Check for exact match first
const existingExact = this.permissions.find(
(it) => this.permissionToString(it) === name,
);
if (existingExact) {
continue;
}
// Check if it's a wildcard pattern (e.g., "admin:api:*")
if (name.endsWith(":*")) {
const groupPrefix = name.slice(0, -2); // Remove ":*"
// Check if any permission exists with this group prefix
const existingWithPrefix = this.permissions.find((it) => {
if (!it.group) return false;
return (
it.group === groupPrefix ||
it.group.startsWith(`${groupPrefix}:`)
);
});
if (existingWithPrefix) {
continue;
}
}
// Permission not found
throw new SecurityError(`Permission '${name}' not found`);
} else {
if (name !== "*" && !this.PERMISSION_REGEXP_WILDCARD.test(name)) {
throw new InvalidPermissionError(name);
}
}
}
realm.roles.push(role);
}
return role;
}
/**
* Adds a permission to the security provider.
*
* @param raw - The permission to add.
*/
public createPermission(raw: Permission | string): Permission {
if (this.alepha.isStarted()) {
throw new ContainerLockedError();
}
let permission: Permission;
if (typeof raw === "string") {
if (!this.PERMISSION_REGEXP.test(raw)) {
throw new InvalidPermissionError(raw);
}
const parts = raw.split(":");
if (parts.length === 1) {
// No group, just name (e.g., "read")
permission = { name: parts[0] };
} else {
// Has group(s) (e.g., "users:read" or "admin:api:users:read")
// The last part is the name, everything else is the group
const name = parts[parts.length - 1];
const groupParts = parts.slice(0, -1);
if (groupParts.length === 1) {
permission = {
group: groupParts[0],
name,
};
} else {
// Multi-layer group
permission = {
group: groupParts.join(":"),
name,
};
}
}
} else {
permission = raw;
}
const asString = this.permissionToString(permission);
if (!this.PERMISSION_REGEXP.test(asString)) {
throw new InvalidPermissionError(asString);
}
const existing = this.permissions.find(
(it) => this.permissionToString(it) === asString,
);
if (existing) {
return existing;
}
this.log.trace(`Creating permission '${asString}'`);
this.permissions.push(permission);
return permission;
}
public createRealm(realm: Realm) {
if (this.realms.length === 1 && this.realms[0].name === "default") {
// if the default realm is the only one, we remove it to allow creating new realms
this.realms.pop();
}
this.realms.push(realm);
}
/**
* Updates the roles for a realm then synchronizes the user account provider if available.
*
* Only available when the app is started.
*
* @param realm - The realm to update the roles for.
* @param roles - The roles to update.
*/
public async updateRealm(realm: string, roles: Role[]): Promise<void> {
if (!this.alepha.isStarted()) {
throw new AppNotStartedError();
}
const realmInstance = this.realms.find((it) => it.name === realm);
if (!realmInstance) {
throw new RealmNotFoundError(realm);
}
realmInstance.roles = roles;
}
// -------------------------------------------------------------------------------------------------------------------
/**
* Creates a user account from the provided payload.
*
* @param payload - The payload to create the user account from.
* @param [realmName] - The realm containing the roles. Default is all.
*
* @returns The user info created from the payload.
*/
public createUserFromPayload(
payload: JWTPayload,
realmName?: string,
): UserAccount {
const id = this.getIdFromPayload(payload);
const sessionId = this.getSessionIdFromPayload(payload);
const rolesFromPayload = this.getRolesFromPayload(payload);
const email = this.getEmailFromPayload(payload);
const username = this.getUsernameFromPayload(payload);
const picture = this.getPictureFromPayload(payload);
const name = this.getNameFromPayload(payload);
const firstName =
typeof payload.given_name === "string" ? payload.given_name : undefined;
const lastName =
typeof payload.family_name === "string" ? payload.family_name : undefined;
const organization = this.getOrganizationFromPayload(payload);
const rolesFromSystem = this.getRoles(realmName);
const roles = rolesFromPayload
.reduce<Role[]>(
(arr, roleName) =>
arr.concat(rolesFromSystem.filter((it) => it.name === roleName)),
[],
)
.map((it) => it.name);
const realm = this.realms.find((it) => it.name === realmName);
if (realm?.profile) {
return realm.profile(payload);
}
return {
id,
roles,
name,
firstName,
lastName,
email,
username,
picture,
organization,
sessionId,
};
}
/**
* Generic user creation from any source (JWT, API key, etc.).
* Handles permission checking, ownership, default roles.
*/
public createUser(
userInfo: UserInfo,
options: {
realm?: string;
permission?: Permission | string;
} = {},
): UserAccountToken {
const realmRoles = this.getRoles(options.realm).filter((it) => it.default);
const roles = [...(userInfo.roles ?? [])];
// Add default roles
for (const role of realmRoles) {
if (!roles.includes(role.name)) {
roles.push(role.name);
}
}
let ownership: string | boolean | undefined;
// Permission check
if (options.permission) {
const check = this.checkPermission(options.permission, ...roles);
if (!check.isAuthorized) {
throw new SecurityError(
`User is not allowed to access '${this.permissionToString(options.permission)}'`,
);
}
ownership = check.ownership;
}
return {
...userInfo,
roles,
ownership,
realm: options.realm,
};
}
/**
* Register a resolver to a realm.
* Resolvers are sorted by priority (lower = first).
*/
public registerResolver(resolver: IssuerResolver, realmName?: string): void {
const realm = this.getRealm(realmName);
if (!realm.resolvers) {
realm.resolvers = [];
}
realm.resolvers.push(resolver);
realm.resolvers.sort((a, b) => (a.priority ?? 100) - (b.priority ?? 100));
}
/**
* Get a realm by name.
* Throws if realm not found.
*/
public getRealm(realmName?: string): Realm {
const realm = realmName
? this.realms.find((it) => it.name === realmName)
: this.realms[0];
if (!realm) {
throw new RealmNotFoundError(realmName ?? "default");
}
return realm;
}
/**
* Resolve user from request using registered resolvers.
* Returns undefined if no resolver could authenticate (no auth provided).
* Throws UnauthorizedError if auth was provided but invalid.
*
* Note: This method tries resolvers from ALL realms to find a match,
* regardless of the `realm` option. The `realm` option is only used for
* permission checking after the user is resolved.
*/
public async resolveUserFromServerRequest(
req: { url: URL | string; headers: { authorization?: string } },
options: {
realm?: string;
permission?: Permission | string;
} = {},
): Promise<UserAccountToken | undefined> {
// Collect all resolvers from all realms with their realm name
const allResolvers: Array<{
resolver: IssuerResolver;
realmName: string;
}> = [];
for (const realm of this.realms) {
for (const resolver of realm.resolvers ?? []) {
allResolvers.push({ resolver, realmName: realm.name });
}
}
// Sort by priority
allResolvers.sort(
(a, b) => (a.resolver.priority ?? 100) - (b.resolver.priority ?? 100),
);
// Try resolvers in priority order
for (const { resolver, realmName } of allResolvers) {
let userInfo: UserInfo | null;
try {
userInfo = await resolver.onRequest(req as any);
} catch {
// Resolver failed (e.g., wrong key), try next
continue;
}
if (userInfo) {
// User was resolved - now create user and check permissions
// (errors from createUser should propagate, not be caught)
const user = this.createUser(userInfo, {
realm: realmName,
permission: options.permission,
});
await this.alepha.events.emit("security:user:created", {
realm: realmName,
user,
});
return user;
}
}
// No resolver matched = no auth provided
return undefined;
}
/**
* Checks if the user has the specified permission.
*
* Bonus: we check also if the user has "ownership" flag.
*
* @param permissionLike - The permission to check for.
* @param roleEntries - The roles to check for the permission.
*/
public checkPermission(
permissionLike: string | Permission,
...roleEntries: string[]
): SecurityCheckResult {
const roles: Role[] = roleEntries.map((it) => {
const role = this.getRoles().find((role) => role.name === it);
if (!role) {
throw new SecurityError(`Role '${it}' not found`);
}
return role;
});
const permission = this.permissionToString(permissionLike);
const isAdmin = roles.find((it) =>
it.permissions.find(
(it) => it.name === "*" && !it.exclude && !it.ownership,
),
);
// if the user is an admin, we can return early
if (isAdmin) {
return {
isAuthorized: true,
ownership: false,
};
}
const result: SecurityCheckResult = {
isAuthorized: false,
ownership: undefined,
};
// Helper function to check if a permission matches a pattern with multi-layer wildcard support
const matchesPattern = (
permissionName: string,
pattern: string,
): boolean => {
if (pattern === "*") return true;
if (pattern === permissionName) return true;
// Handle multi-layer wildcards (e.g., "admin:api:*" matches "admin:api:users:read")
if (pattern.endsWith(":*")) {
const patternPrefix = pattern.slice(0, -2);
// Check if permission starts with the pattern prefix
if (permissionName === patternPrefix) return false; // "admin:api" doesn't match "admin:api:*"
return permissionName.startsWith(`${patternPrefix}:`);
}
return false;
};
for (const role of roles) {
// for each role candidate
for (const rolePermission of role.permissions) {
// for each permission in the role
if (matchesPattern(permission, rolePermission.name)) {
// [feature]: exclude permissions including wildcards
if (rolePermission.exclude) {
let isExcluded = false;
for (const excludePattern of rolePermission.exclude) {
if (matchesPattern(permission, excludePattern)) {
isExcluded = true;
break;
}
}
if (isExcluded) {
continue;
}
}
result.isAuthorized = true; // OK !
// but we also need to check if the user has ownership
if (rolePermission.ownership) {
// if ownership is true, we have to check all other matching permissions in case of ownership === false ...
result.ownership = rolePermission.ownership;
} else {
// but if isAuthorized && ownership === false, we can break the loop \ :D /
result.ownership = false;
return result;
}
}
}
}
return result;
}
/**
* Creates a user account from the provided payload.
*/
public async createUserFromToken(
headerOrToken?: string,
options: {
permission?: Permission | string;
realm?: string;
verify?: JWTVerifyOptions;
} = {},
): Promise<UserAccountToken> {
const token = headerOrToken?.replace(/^Bearer\s+/i, "").trim();
if (typeof token !== "string" || token === "") {
throw new InvalidTokenError(
"Invalid authorization header, maybe token is missing ?",
);
}
const { result, keyName: realm } = await this.jwt.parse(
token,
options.realm,
options.verify,
);
const info = this.createUserFromPayload(result.payload, realm);
const realmRoles = this.getRoles(realm).filter((it) => it.default);
const roles = info.roles ?? [];
for (const role of realmRoles) {
if (!roles.includes(role.name)) {
roles.push(role.name);
}
}
info.roles = roles;
await this.alepha.events.emit("security:user:created", {
realm,
user: info,
});
let ownership: string | boolean | undefined;
if (options.permission) {
const check = this.checkPermission(options.permission, ...roles);
if (!check.isAuthorized) {
throw new SecurityError(
`User is not allowed to access '${this.permissionToString(options.permission)}'`,
);
}
ownership = check.ownership;
}
return {
...info,
ownership,
token,
realm,
};
}
/**
* Checks if a user has a specific role.
*
* @param roleName - The role to check for.
* @param permission - The permission to check for.
* @returns True if the user has the role, false otherwise.
*/
public can(roleName: string, permission: string | Permission): boolean {
return this.checkPermission(permission, roleName).isAuthorized;
}
/**
* Checks if a user has ownership of a specific permission.
*/
public ownership(
roleName: string,
permission: string | Permission,
): string | boolean | undefined {
return this.checkPermission(permission, roleName).ownership;
}
/**
* Converts a permission object to a string.
*
* @param permission
*/
public permissionToString(permission: Permission | string): string {
if (typeof permission === "string") {
return permission;
}
if (!permission.group) {
return permission.name;
}
// Handle multi-layer groups (e.g., "admin:api" or "management:users")
const groupParts = Array.isArray(permission.group)
? permission.group
: [permission.group];
return `${groupParts.join(":")}:${permission.name}`;
}
/**
* Check that the user belongs to one of the required issuers.
*/
public checkIssuers(user: UserAccountToken, issuers?: string[]) {
if (issuers?.length && (!user.realm || !issuers.includes(user.realm))) {
throw new ForbiddenError(
`User must belong to issuer '${issuers.join("' or '")}' to access this route`,
);
}
}
/**
* Store the resolved user in the atom for downstream access (useAuth, audit trail, etc.).
*/
public storeUserInContext(user: UserAccountToken) {
const decoded = this.alepha.codec.decode(userAccountInfoSchema, user);
this.alepha.store.set(currentUserAtom, decoded);
}
// accessors
public getRealms(): Realm[] {
return this.realms;
}
/**
* Resolve the realm an authenticated admin action is allowed to operate on.
*
* Admin endpoints accept a client-supplied target realm (e.g. a
* `userRealmName` query param). Without scoping, a realm-A admin could act on
* realm-B resources by simply naming realm B — a cross-tenant escalation
* whenever realms are used as tenant boundaries. This binds the target realm
* to the caller's own realm: omitting the target means "my realm"; naming a
* *different* realm than the caller's is refused.
*
* Returns the effective realm to use (may be `undefined` in single-realm apps
* where neither the caller nor the request carries a realm).
*/
public assertRealmScope(
user: { realm?: string } | undefined,
requestedRealm: string | undefined,
): string | undefined {
const callerRealm = user?.realm;
if (requestedRealm && callerRealm && requestedRealm !== callerRealm) {
throw new ForbiddenError(
`Cross-realm access denied: authenticated in realm '${callerRealm}', requested realm '${requestedRealm}'.`,
);
}
return requestedRealm ?? callerRealm;
}
/**
* Retrieves the user account from the provided user ID.
*
* @param realm
*/
public getRoles(realm?: string): Role[] {
if (realm) {
return [...(this.realms.find((it) => it.name === realm)?.roles ?? [])];
}
return this.realms.reduce<Role[]>((arr, it) => arr.concat(it.roles), []);
}
/**
* Returns all permissions.
*
* @param user - Filter permissions by user.
*
* @return An array containing all permissions.
*/
public getPermissions(user?: {
roles?: Array<Role | string>;
realm?: string;
}): Permission[] {
if (user?.roles) {
const permissions: Permission[] = [];
const roles = user.roles ?? [];
for (const roleOrString of roles) {
const role =
typeof roleOrString === "string"
? this.getRoles(user.realm).find((it) => it.name === roleOrString)
: roleOrString;
if (!role) {
throw new SecurityError(`Role '${roleOrString}' not found`);
}
if (role.permissions.some((it) => it.name === "*" && !it.exclude)) {
return this.getPermissions();
}
for (const permission of role.permissions) {
let ref: Permission[] = [];
if (permission.name === "*") {
ref.push(...this.permissions);
} else if (permission.name.includes(":")) {
// Handle multi-layer wildcards (e.g., "admin:api:*" or "users:read")
const parts = permission.name.split(":");
const lastPart = parts[parts.length - 1];
if (lastPart === "*") {
// Wildcard at any level (e.g., "admin:*", "admin:api:*")
const groupPrefix = parts.slice(0, -1).join(":");
ref.push(
...this.permissions.filter((it) => {
if (!it.group) return false;
// Match exact group or any sub-group
return (
it.group === groupPrefix ||
it.group.startsWith(`${groupPrefix}:`)
);
}),
);
} else {
// Specific permission (e.g., "users:read" or "admin:api:users:read")
const name = lastPart;
const groupParts = parts.slice(0, -1);
const group = groupParts.join(":");
ref.push(
...this.permissions.filter((it) => {
if (it.name !== name) return false;
if (!it.group) return false;
return it.group === group;
}),
);
}
} else {
// all permissions without a group
ref.push(
...this.permissions.filter(
(it) => it.name === permission.name && !it.group,
),
);
}
const exclude = permission.exclude;
if (exclude) {
// exclude permissions with multi-layer wildcard support
ref = ref.filter((it) => {
const permString = this.permissionToString(it);
return !exclude.some((excludePattern) => {
if (excludePattern === permString) return true;
if (excludePattern.endsWith(":*")) {
const excludePrefix = excludePattern.slice(0, -2);
return permString.startsWith(`${excludePrefix}:`);
}
return false;
});
});
}
permissions.push(...ref);
}
}
return [...new Set(permissions.filter((it) => it != null))];
}
return this.permissions;
}
/**
* Retrieves the user ID from the provided payload object.
*
* @param payload - The payload object from which to extract the user ID.
* @return The user ID as a string.
*/
public getIdFromPayload(payload: Record<string, any>): string {
if (payload.sub != null) {
return String(payload.sub);
}
if (payload.id != null) {
return String(payload.id);
}
if (payload.userId != null) {
return String(payload.userId);
}
throw new SecurityError("Invalid JWT - missing id");
}
public getSessionIdFromPayload(
payload: Record<string, any>,
): string | undefined {
if (!payload) {
return;
}
if (payload.sid) {
return String(payload.sid);
}
}
/**
* Retrieves the roles from the provided payload object.
* @param payload - The payload object from which to extract the roles.
* @return An array of role strings.
*/
public getRolesFromPayload(payload: Record<string, any>): string[] {
return payload?.realm_access?.roles ?? payload?.roles ?? [];
}
public getPictureFromPayload(
payload: Record<string, any>,
): string | undefined {
if (!payload) {
return;
}
if (payload.picture) {
return payload.picture;
}
if (payload.avatar_url) {
return payload.avatar_url;
}
if (payload.user_picture) {
return payload.user_picture;
}
return undefined;
}
public getUsernameFromPayload(
payload: Record<string, any>,
): string | undefined {
if (!payload) {
return;
}
if (payload.preferred_username) {
return payload.preferred_username;
}
if (payload.username) {
return payload.username;
}
return undefined;
}
public getEmailFromPayload(payload: Record<string, any>): string | undefined {
if (!payload) {
return;
}
if (payload.email) {
return payload.email;
}
return undefined;
}
/**
* Returns the name from the given payload.
*
* @param payload - The payload object.
* @returns The name extracted from the payload, or an empty string if the payload is falsy or no name is found.
*/
public getNameFromPayload(payload: Record<string, any>): string {
if (!payload) {
return this.UNKNOWN_USER_NAME;
}
if (payload.name) {
return payload.name;
}
if (
typeof payload.given_name === "string" &&
typeof payload.family_name === "string"
) {
return `${payload.given_name} ${payload.family_name}`.trim();
}
return this.UNKNOWN_USER_NAME;
}
public getOrganizationFromPayload(
payload: Record<string, any>,
): string | undefined {
if (!payload) {
return;
}
if (typeof payload.organization === "string") {
return payload.organization;
}
}
/**
* Extracts the tenant id from the JWT payload, when present.
*
* Tokens minted with no active tenant (single-tenant apps, server-to-server
* calls before any request-scoped middleware runs) omit the claim, in which
* case the resolver does not enforce a tenant match.
*/
public getTenantFromPayload(
payload: Record<string, any>,
): string | undefined {
if (!payload) {
return;
}
if (typeof payload.tenant === "string") {
return payload.tenant;
}
}
}
// =====================================================================================================================
/**
* A realm definition.
*/
export interface Realm {
name: string;
roles: Role[];
/**
* The secret key for the realm.
*
* Can be also a JWKS URL.
*/
secret?: string | JSONWebKeySet | (() => string);
/**
* Asymmetric signing config. When set, this realm signs its tokens with an
* asymmetric key and publishes the public keys via JWKS (see
* `JwtProvider.getJwks`). Takes precedence over `secret`.
*/
signing?: SigningConfig;
/**
* Create the user account info based on the raw JWT payload.
* By default, SecurityProvider has his own implementation, but this method allow to override it.
*/
profile?: (raw: Record<string, any>) => UserAccount;
/**
* Custom resolvers for this realm (sorted by priority).
*/
resolvers?: IssuerResolver[];
}
export interface SecurityCheckResult {
isAuthorized: boolean;
ownership: string | boolean | undefined;
}