Protecting an API
An API protected by Origin Auth accepts the access tokens auth issues, and checks on each request that the token is valid and grants what the request needs. Both SDKs do this for you, with the API’s scopes declared as types, so a misspelled scope doesn’t compile.
The code on this page comes from the SDKs’ examples,
examples/notes.rs
and
examples/resource.ts.
Before you start
- Know which tokens your API accepts: those whose
audis its identifier. For an app registered in the console, that is the app’s client ID, so an API that serves your own app uses your app’s client ID as its identifier. The examples below usenotes-api, which stands for an audience an operator registered for a first-party API. How sign-in works explains the tokens. - Add an SDK to your API, as signing in with Rust
or with TypeScript shows. The Rust
layer and extractors below need its
axumfeature.
Validate tokens
Each request sends its access token as Authorization: Bearer <token>. The SDKs
read it from that header only. They check the token’s signature against auth’s
keys, its issuer, its aud and its times, with 30 seconds of leeway for clock
skew.
Rust
Declare the API’s scopes once with scopes!:
#![allow(unused)]
fn main() {
sourceorigin_auth::scopes! {
/// Scopes of the notes API.
pub mod notes {
/// Read every note.
Read = "notes:read",
/// Add notes.
Write = "notes:write",
}
}
}
Add a BearerLayer for the API’s identifier, the aud its tokens carry:
#![allow(unused)]
fn main() {
let issuer =
env::var("SOURCEORIGIN_ISSUER").unwrap_or_else(|_| "https://auth.sourceorigin.dev".into());
let issuer = Issuer::discover(&issuer).await?;
let api = Router::new()
.route("/notes", get(list).post(create))
.with_state(Notes::default())
.layer(BearerLayer::new(
issuer.resource(Resource::new("notes-api")?),
));
}
Then take what each handler needs:
#![allow(unused)]
fn main() {
/// `GET /api/notes`: needs `notes:read`.
async fn list(State(notes): State<Notes>, _: Scoped<notes::Read>) -> Json<Vec<String>> {
Json(notes.lock().unwrap_or_else(PoisonError::into_inner).clone())
}
/// `POST /api/notes`: needs `notes:read` and `notes:write`.
async fn create(
State(notes): State<Notes>,
_: Scoped<(notes::Read, notes::Write)>,
Json(note): Json<String>,
) -> StatusCode {
notes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(note);
StatusCode::CREATED
}
}
Scoped<S>requires the scopesS, one or a tuple of them. A request without a token gets401, naming the scopes, and a token without them gets403insufficient_scope.Authenticatedrequires a valid token, and nothing more.- Each holds the validated
AccessToken. Itssubject()is the user it acts for,Subject::User, orSubject::Clientfor an app acting for itself.
A request without a token passes through the layer, so public routes can share
the router: only the extractors refuse it. Outside axum, ResourceValidator
validates a token string with validate::<S>(token).
TypeScript
Declare the API’s scopes with defineScopes, and make a resource for the API’s
identifier, the aud its tokens carry:
import { AuthError, Issuer, defineScopes } from '@sourceorigin/auth';
const issuer = await Issuer.discover('https://auth.sourceorigin.dev');
const notes = defineScopes('notes:read', 'notes:write');
const api = issuer.resource({ identifier: 'notes-api', scopes: notes });
Then authenticate each request:
/** `POST /notes`: needs `notes:write`, and a passkey sign-in within five minutes. */
export async function handle(request: Request): Promise<Response> {
try {
// A request without a token is told it needs notes:write; 'notes:delete' would
// not compile.
const token = await api.authenticate(request, 'notes:write');
token.requireAuthentication({ acr_values: 'phr', max_age: 300 });
return Response.json({ user: token.subject.id }, { status: 201 });
} catch (error) {
// The status and `WWW-Authenticate` challenge, or 503 when keys are unavailable.
if (error instanceof AuthError) return error.toResponse();
throw error;
}
}
authenticate(request, ...scopes)validates the request’s token and requires the scopes. A request without a token is refused, naming the scopes.validate(token)validates a token string.token.require(...scopes)requires more scopes later, andtoken.has(scope)checks one without throwing. A scope you didn’t declare doesn’t compile.token.subjectis{ kind: 'user', id }, or{ kind: 'client', id }for an app acting for itself.- Every failure is an
AuthError.toResponse()answers with its status andWWW-Authenticatechallenge.
Scopes
Besides openid, profile, email and offline_access, an API’s own scopes,
such as notes:read, are granted only to clients SourceOrigin’s operators register
with them. Apps registered in the developer console can’t request them, so an API
that serves those apps checks the user, not a scope: take Authenticated in
Rust, and authenticate(request) without scopes in TypeScript.
Ask for a recent passkey
An operation that needs a stronger or more recent sign-in checks the token’s
authentication, and answers with a step-up challenge when it falls short. The
TypeScript example does this with requireAuthentication. In Rust, the
RequireAuthentication<P> extractor does it for a policy P.
Step-up authentication explains how to choose the requirement and
how clients answer the challenge.
Read your own claims
A resource can also read claims that auth doesn’t define, such as a tenant, from the tokens it validates:
- In Rust,
issuer.resource(...).with_claims::<X>()deserializes them intoX, which the token holds asextra(). - In TypeScript, the resource’s
claimsoption reads them intotoken.extra:claims: (claims) => ({ tenant: String(claims.tenant) }). Throwing fails the token withinvalid_claims.
Verify tokens from another issuer
KeyCache verifies JSON Web Signatures against any JWKS URL, such as Google’s.
It fetches the keys again once for an unknown key ID, and at most every 30
seconds:
import { Jws, KeyCache } from '@sourceorigin/auth';
const keys = new KeyCache('https://www.googleapis.com/oauth2/v3/certs');
const jws = Jws.parse(token);
await keys.verify(jws, jws.requireAlgorithm(['RS256']));
const claims: unknown = JSON.parse(new TextDecoder().decode(jws.payload));
In Rust, KeyCache::new(jwks_uri, http) and Jws do the same, where http is
a reqwest::ClientBuilder for proxies or extra trusted roots. Either way, the
JWKS URL must use https, and the SDKs follow no redirect and give up on a
request after 10 seconds, whatever the HTTP client you give them. To pin keys, or
verify without the network, give a parsed KeySet to KeyCache.fixed in
TypeScript, or KeyCache::fixed in Rust: it never fetches, so a key ID the set
lacks fails with unknown_key.
Check that it works
- Call the API without a token. It answers
401, with aWWW-Authenticateheader that names the scopes the route needs, if it needs any. - Call it with an access token from a signed-in app, as
Authorization: Bearer <token>. It answers. - Call it with a token lacking a scope. It answers
403, witherror="insufficient_scope".
Troubleshooting
- Every token is refused with
invalid_token: check the identifier. It must equal the token’saudexactly: your app’s client ID, for an app registered in the console. - The API answers
503: it could not fetch auth’s signing keys. It answers again once auth is reachable. - A Rust handler answers
500: the route is missingBearerLayer.