@plinthjs/passport
v0.1.0
Published
Mason OAuth2 server (the Laravel Passport equivalent).
Maintainers
Readme
@plinthjs/passport
A dependency-free OAuth2 authorization server for Mason, the Laravel Passport equivalent. It ships
client registration, the four core grants (authorization code with PKCE S256, client credentials,
refresh token, password), a scope registry, an AuthorizationServer that drives the authorize,
token and revoke endpoints, and a ResourceServer that protects APIs. Persistence sits behind store
interfaces with in-memory Array* defaults, and the clock and id/secret generators are injectable.
Install
npm install @plinthjs/passportUsage
import {
ArrayAuthCodeStore,
ArrayClientStore,
ArrayRefreshTokenStore,
ArrayTokenStore,
AuthorizationServer,
Client,
hashClientSecret,
ResourceServer,
ScopeRegistry,
} from '@plinthjs/passport'
const clients = new ArrayClientStore([
Client.from({
id: 'client-1',
name: 'First Party',
secretHash: hashClientSecret('client-secret'),
redirectUris: ['https://app.test/callback'],
grantTypes: ['authorization_code', 'client_credentials', 'refresh_token'],
scopes: ['users:read', 'users:write'],
confidential: true,
}),
])
const tokens = new ArrayTokenStore()
const server = new AuthorizationServer({
clients,
tokens,
refreshTokens: new ArrayRefreshTokenStore(),
authCodes: new ArrayAuthCodeStore(),
scopes: new ScopeRegistry()
.define('users:read', 'Read users')
.define('users:write', 'Write users'),
ttl: { accessTokenTtlSeconds: 3600 },
})
// Machine-to-machine: no user, no refresh token.
const issued = server.issueToken('client_credentials', {
client_id: 'client-1',
client_secret: 'client-secret',
scope: 'users:read',
})
issued.accessToken // 'id.secret', returned exactly once
issued.expiresIn // 3600
// Protect an API.
const resource = new ResourceServer({ tokens })
resource.validate(issued.accessToken) // AccessToken | null
resource.validateForScope(issued.accessToken, 'users:read') // trueAuthorization code flow
// GET /oauth/authorize: validate, then show the consent screen.
const request = server.validateAuthorizationRequest(query) // client_id, redirect_uri, scope, state, code_challenge...
// After the user decides:
const { redirectTo } = server.completeAuthorizationRequest({
request,
userId: '42',
approved: true,
})
// POST /oauth/token: exchange the code (code_verifier required when a PKCE challenge was sent).
const pair = server.issueToken('authorization_code', body)
const rotated = server.issueToken('refresh_token', {
refresh_token: pair.refreshToken!,
client_id: 'client-1',
client_secret: 'client-secret',
})Public (secret-less) clients must use PKCE; deriveCodeChallenge(verifier) computes the S256
challenge. Refresh tokens rotate: the presented pair is revoked and a new pair is issued. The password
grant is enabled only when verifyCredentials: (username, password) => userId | null is configured.
Revocation
server.revokeToken(issued.accessToken) // true
server.revokeToken(pair.accessToken) // also revokes its paired refresh token
server.revokeToken(rotated.refreshToken!, 'refresh_token') // optional token_type_hintUnknown tokens return false rather than throwing (RFC 7009).
Tokens and errors
Tokens and codes are opaque, not JWTs: the plaintext id.secret is returned once and only
sha256(secret) is stored, verified in constant time. Tokens are therefore only verifiable by this
server. A resolved AccessToken exposes clientId, userId, scopes, can(scope) and cant(scope).
Every failure throws an OAuthError with an RFC 6749 code:
import { OAuthError } from '@plinthjs/passport'
function tokenEndpoint(grantType: string, params: Record<string, string>) {
try {
return { status: 200, body: server.issueToken(grantType, params) }
} catch (error) {
if (error instanceof OAuthError) {
return { status: 400, body: error.toResponseBody() } // { error, error_description }
}
throw error
}
}