Web authentication integration support for the Travetto framework
npm install @travetto/auth-webInstall: @travetto/auth-web
``bash
npm install @travetto/auth-web
yarn add @travetto/auth-web
`
This is a primary integration for the Authentication module with the Web API module.
The integration with the Web API module touches multiple levels. Primarily:
* Authenticating
* Maintaining Auth Context
* Endpoint Decoration
* Multi-Step Login
Code: Structure for the Identity Source
`typescript
import type { AnyMap } from '@travetto/runtime';
import type { Principal } from './principal.ts';
/**
* Represents the general shape of additional login context, usually across multiple calls
*
* @concrete
*/
export interface AuthenticatorState extends AnyMap { }
/**
* Supports validation payload of type T into an authenticated principal
*
* @concrete
*/
export interface Authenticator
/**
* Retrieve the authenticator state for the given request
*/
getState?(context?: C): Promise
/**
* Verify the payload, ensuring the payload is correctly identified.
*
* @returns Valid principal if authenticated
* @returns undefined if authentication is valid, but incomplete (multi-step)
* @throws AppError if authentication fails
*/
authenticate(payload: T, context?: C): Promise
| P | undefined;
}
`
The only required method to be defined is the authenticate method. This takes in a pre-principal payload and a filter context with a WebRequest, and is responsible for:
* Returning an Principal if authentication was successful
* Throwing an error if it failed
* Returning undefined if the authentication is multi-staged and has not completed yet
A sample auth provider would look like:
Code: Sample Identity Source
`typescript
import { AuthenticationError, type Authenticator } from '@travetto/auth';
type User = { username: string, password: string };
export class SimpleAuthenticator implements Authenticator
async authenticate({ username, password }: User) {
if (username === 'test' && password === 'test') {
return {
id: 'test',
source: 'simple',
permissions: [],
details: {
username: 'test'
}
};
} else {
throw new AuthenticationError('Invalid credentials');
}
}
}
`
The provider must be registered with a custom symbol to be used within the framework. At startup, all registered Authenticator's are collected and stored for reference at runtime, via symbol. For example:
Code: Potential Facebook provider
`typescript
import { InjectableFactory } from '@travetto/di';
import { SimpleAuthenticator } from './source.ts';
export const FbAuthSymbol = Symbol.for('auth-facebook');
export class AppConfig {
@InjectableFactory(FbAuthSymbol)
static facebookIdentity() {
return new SimpleAuthenticator();
}
}
`
The symbol FB_AUTH is what will be used to reference providers at runtime. This was chosen, over class references due to the fact that most providers will not be defined via a new class, but via an @InjectableFactory method.
Note for Cookie Use: The automatic renewal, update, seamless receipt and transmission of the Principal cookie act as a light-weight session. Generally the goal is to keep the token as small as possible, but for small amounts of data, this pattern proves to be fairly sufficient at maintaining a decentralized state.
The PrincipalCodec contract is the primary interface for reading and writing Principal data out of the WebRequest. This contract is flexible by design, allowing for all sorts of usage. JWTPrincipalCodec is the default PrincipalCodec, leveraging JWTs for encoding/decoding the principal information.
Code: JWTPrincipalCodec
`typescript
import type { Jwt, Verifier, SupportedAlgorithms } from 'njwt';
import { type AuthContext, AuthenticationError, type AuthToken, type Principal } from '@travetto/auth';
import { Injectable, Inject } from '@travetto/di';
import { type WebResponse, type WebRequest, type WebAsyncContext, CookieJar } from '@travetto/web';
import { AppError, castTo, TimeUtil } from '@travetto/runtime';
import { CommonPrincipalCodecSymbol, type PrincipalCodec } from './types.ts';
import type { WebAuthConfig } from './config.ts';
/**
* JWT Principal codec
*/
@Injectable(CommonPrincipalCodecSymbol)
export class JWTPrincipalCodec implements PrincipalCodec {
@Inject()
config: WebAuthConfig;
@Inject()
authContext: AuthContext;
@Inject()
webAsyncContext: WebAsyncContext;
#verifier: Verifier;
#algorithm: SupportedAlgorithms = 'HS256';
async postConstruct(): Promise
// Weird issue with their ES module support
const { default: { createVerifier } } = await import('njwt');
this.#verifier = createVerifier()
.setSigningAlgorithm(this.#algorithm)
.withKeyResolver((keyId, callback) => {
const entry = this.config.keyMap[keyId];
return callback(entry ? null : new AuthenticationError('Invalid'), entry.key);
});
}
async verify(token: string): Promise
try {
const jwt: Jwt & { body: { core: Principal } } = await new Promise((resolve, reject) =>
this.#verifier.verify(token, (error, verified) => error ? reject(error) : resolve(castTo(verified)))
);
return jwt.body.core;
} catch (error) {
if (error instanceof Error && error.name.startsWith('Jwt')) {
throw new AuthenticationError(error.message, { category: 'permissions' });
}
throw error;
}
}
token(request: WebRequest): AuthToken | undefined {
const value = (this.config.mode === 'header') ?
request.headers.getWithPrefix(this.config.header, this.config.headerPrefix) :
this.webAsyncContext.getValue(CookieJar).get(this.config.cookie, { signed: false });
return value ? { type: 'jwt', value } : undefined;
}
async decode(request: WebRequest): Promise
const token = this.token(request);
return token ? await this.verify(token.value) : undefined;
}
async create(value: Principal, keyId: string = 'default'): Promise
const entry = this.config.keyMap[keyId];
if (!entry) {
throw new AppError('Requested unknown key for signing');
}
// Weird issue with their ES module support
const { default: { create } } = await import('njwt');
const jwt = create({}, '-')
.setExpiration(value.expiresAt!)
.setIssuedAt(TimeUtil.asSeconds(value.issuedAt!))
.setClaim('core', castTo({ ...value }))
.setIssuer(value.issuer!)
.setJti(value.sessionId!)
.setSubject(value.id)
.setHeader('kid', entry.id)
.setSigningKey(entry.key)
.setSigningAlgorithm(this.#algorithm);
return jwt.toString();
}
async encode(response: WebResponse, data: Principal | undefined): Promise
const token = data ? await this.create(data) : undefined;
const { header, headerPrefix, cookie } = this.config;
if (this.config.mode === 'header') {
response.headers.setWithPrefix(header, token, headerPrefix);
} else {
this.webAsyncContext.getValue(CookieJar).set({ name: cookie, value: token, signed: false, expires: data?.expiresAt });
}
return response;
}
}
`
As you can see, the encode token just creates a JWT based on the principal provided, and decoding verifies the token, and returns the principal.
A trivial/sample custom PrincipalCodec can be seen here:
Code: Custom Principal Codec
`typescript
import type { Principal } from '@travetto/auth';
import type { PrincipalCodec } from '@travetto/auth-web';
import { Injectable } from '@travetto/di';
import { BinaryUtil } from '@travetto/runtime';
import type { WebResponse, WebRequest } from '@travetto/web';
@Injectable()
export class CustomCodec implements PrincipalCodec {
secret: string;
decode(request: WebRequest): Promise
const [userId, sig] = request.headers.get('USER_ID')?.split(':') ?? [];
if (userId && sig === BinaryUtil.hash(userId + this.secret)) {
let principal: Principal | undefined;
// Lookup user from db, remote system, etc.,
return principal;
}
return;
}
encode(response: WebResponse, data: Principal | undefined): WebResponse {
if (data) {
response.headers.set('USER_ID', ${data.id}:${BinaryUtil.hash(data.id + this.secret)});`
}
return response;
}
}
This implementation is not suitable for production, but shows the general pattern needed to integrate with any principal source.
@Logout integrates with middleware that will automatically deauthenticate a user, throw an error if the user is unauthenticated.
Code: Using provider with endpoints
`typescript
import { Controller, Get, ContextParam, WebResponse } from '@travetto/web';
import { Login, Authenticated, Logout } from '@travetto/auth-web';
import type { Principal } from '@travetto/auth';
import { FbAuthSymbol } from './facebook.ts';
@Controller('/auth')
export class SampleAuth {
@ContextParam()
user: Principal;
@Get('/simple')
@Login(FbAuthSymbol)
async simpleLogin() {
return WebResponse.redirect('/auth/self');
}
@Get('/self')
@Authenticated()
async getSelf() {
return this.user;
}
@Get('/logout')
@Logout()
async logout() {
return WebResponse.redirect('/auth/self');
}
}
`
@Authenticated and @Unauthenticated will simply enforce whether or not a user is logged in and throw the appropriate error messages as needed. Additionally, the Principal is accessible as a resource that can be exposed as a @ContextParam on an @Injectable class.