Skip to content

Commit ff73bd8

Browse files
committed
docs(auth): expand authorization-code/PKCE page + add Vue/Nuxt/SvelteKit/Angular/JS quickstarts
- authorization-code: add 'What is PKCE (how it works)', 'How to generate the code_verifier and code_challenge' (Web Crypto + Node snippets), flow comparison table, expanded FAQ, and a framework-quickstart hub link. Targets the high-volume PKCE query cluster (766 impr / pos ~37 / 0 clicks in GSC). - new quickstarts using core @faable/auth-js (onAuthStateChange + getSession): javascript (vanilla base pattern), vue, nuxt, sveltekit, angular. - nextjs: keyword-rich title, schema:faq + FAQ/Related, connection -> connection_id. - register new pages in quickstart/_meta.ts. - verified: next build compiles all MDX + check-links (5625 internal links resolve).
1 parent cbcb435 commit ff73bd8

8 files changed

Lines changed: 1076 additions & 3 deletions

File tree

‎content/auth/oauth-flows/authorization-code.mdx‎

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@ For backend services calling APIs **without a user**, use the [Client Credential
1818

1919
---
2020

21+
## 🔑 What is PKCE, and how does it work?
22+
23+
**PKCE (Proof Key for Code Exchange, [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) — pronounced "pixy")** is an extension to the OAuth 2.0 Authorization Code flow that makes it safe for clients that **cannot keep a secret** — single-page apps, mobile, and native apps whose code ships to the user's device.
24+
25+
It closes the **authorization-code interception attack**. Without PKCE, anything that captures the `code` on its way back to your app — a malicious app registered on the same mobile URL scheme, a leaky proxy, browser history — could exchange it for tokens. PKCE ties the code to the exact client that started the flow:
26+
27+
1. Your app generates a random secret, the **`code_verifier`**, and keeps it locally.
28+
2. It sends only the **`code_challenge`** — `base64url(SHA-256(code_verifier))` — when it redirects to `/authorize`. The hash is one-way, so the challenge is useless to an attacker.
29+
3. When exchanging the `code` for tokens, your app sends the original `code_verifier`. Faable recomputes the hash and **rejects the exchange unless it matches** the challenge from step 2.
30+
31+
A stolen `code` is worthless without the `code_verifier`, which never left your app. That is why PKCE is now the recommended practice for **every** client type — including confidential, server-side apps.
32+
33+
### Authorization Code + PKCE vs other flows
34+
35+
| Your app | Use |
36+
| :-- | :-- |
37+
| SPA, mobile, native, or server-side web app logging a **user** in | **Authorization Code + PKCE** (this page) |
38+
| Backend service / cron / CI calling an API with **no user** | [Client Credentials](client-credentials.md) |
39+
| Renewing an access token without re-login | [Refresh Token](refresh-token.md) |
40+
41+
> The older **Implicit flow** (tokens returned in the URL fragment) is deprecated — never put tokens in URLs. Authorization Code + PKCE replaces it for public clients.
42+
43+
---
44+
2145
## 📸 How It Works
2246

2347
```mermaid
@@ -81,6 +105,43 @@ That's it — the SDK also refreshes the access token transparently when it expi
81105
> [!IMPORTANT]
82106
> The `redirectTo` URL must be listed in the client's **Allowed Callback URLs** in the dashboard, or the request will be rejected.
83107
108+
**Using a framework?** Follow a copy-paste quickstart instead: [React](../quickstart/react.md) · [Next.js](../quickstart/nextjs.md) · [Vue](../quickstart/vue.md) · [SvelteKit](../quickstart/sveltekit.md) · [Angular](../quickstart/angular.md) · [JavaScript (vanilla)](../quickstart/javascript.md) · [React Native](../quickstart/react-native.md).
109+
110+
---
111+
112+
## 🧩 How to Generate the `code_verifier` and `code_challenge`
113+
114+
If you use `@faable/auth-js`, **skip this** — the SDK generates, stores, and sends these for you. Rolling your own? Here's the exact PKCE pair generation.
115+
116+
The **`code_verifier`** is a high-entropy random string (43–128 chars from the unreserved set). The **`code_challenge`** is its SHA-256 hash, base64url-encoded — `code_challenge_method=S256`, the only method Faable supports.
117+
118+
**Browser (Web Crypto API):**
119+
120+
```ts
121+
function base64url(bytes: ArrayBuffer): string {
122+
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
123+
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
124+
}
125+
126+
// code_verifier — 32 random bytes → base64url (~43 chars)
127+
const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32)).buffer);
128+
129+
// code_challenge = base64url(SHA-256(code_verifier))
130+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
131+
const codeChallenge = base64url(digest);
132+
```
133+
134+
**Node.js:**
135+
136+
```ts
137+
import { randomBytes, createHash } from "node:crypto";
138+
139+
const codeVerifier = randomBytes(32).toString("base64url");
140+
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
141+
```
142+
143+
Keep the `code_verifier` until the callback — it must survive the redirect (e.g. `sessionStorage` in a SPA). Send the `code_challenge` on `/authorize`, then send the original `code_verifier` back on the token exchange.
144+
84145
---
85146

86147
## 🛠️ Step-by-Step over HTTP
@@ -235,6 +296,18 @@ With whatever [connections](../connections.md) you enable on the client: email/p
235296
### When should I use Client Credentials instead?
236297
When there is no user — a backend service, cron job, or CI pipeline calling an API. See [Client Credentials](client-credentials.md).
237298

299+
### How do I generate a `code_verifier` and `code_challenge`?
300+
The verifier is a random high-entropy string; the challenge is `base64url(SHA-256(verifier))`. See [How to Generate the code_verifier and code_challenge](#how-to-generate-the-code_verifier-and-code_challenge) for copy-paste browser and Node snippets — or let `@faable/auth-js` do it for you.
301+
302+
### Is PKCE required?
303+
For public clients (SPA, mobile, native) it's mandatory in practice — the flow is unsafe without it. For confidential server-side clients it's strongly recommended and the current OAuth 2.0 best practice. Faable supports it for every client type.
304+
305+
### What is `code_challenge_method=S256`?
306+
It tells the server the challenge is the SHA-256 hash of the verifier (not the plaintext). `S256` is the only method Faable accepts — plain challenges are rejected.
307+
308+
### Does PKCE work with refresh tokens?
309+
Yes. The code exchange returns a `refresh_token` alongside the access token; renew silently with the [Refresh Token flow](refresh-token.md). `@faable/auth-js` refreshes for you automatically.
310+
238311
---
239312

240313
## 🔗 Related

‎content/auth/quickstart/_meta.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
export default {
22
react: "React (SPA)",
33
nextjs: "Next.js",
4+
vue: "Vue",
5+
nuxt: "Nuxt",
6+
sveltekit: "SvelteKit",
7+
angular: "Angular",
8+
javascript: "JavaScript (Vanilla)",
49
"react-native": "React Native",
510
};
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
---
2+
schema: faq
3+
title: Angular Authentication — OAuth PKCE Login with Faable Auth
4+
description: Add OAuth 2.0 login with PKCE to a standalone Angular app using @faable/auth-js and signals. Create a root AuthService, expose the session as a signal, sign in, read the user, call your API, and sign out.
5+
---
6+
7+
# Angular Quickstart 🅰️
8+
9+
Add a complete login experience to a **standalone Angular** app (Angular 17+). `@faable/auth-js` drives the [Authorization Code flow with PKCE](../oauth-flows/authorization-code.md), stores the session, refreshes tokens, and syncs across tabs. You wire it to Angular's reactivity with a single root service that exposes the session as a **signal**.
10+
11+
This is the framework-agnostic core pattern: **`createClient` + `onAuthStateChange` + `getSession`**. The [JavaScript](javascript.md), [Vue](vue.md), and [SvelteKit](sveltekit.md) quickstarts are the same three calls wired into each framework — here they live in an injectable `AuthService`.
12+
13+
You need one package: **[`@faable/auth-js`](https://www.npmjs.com/package/@faable/auth-js)** — no Angular helper required.
14+
15+
---
16+
17+
## ✅ Prerequisites
18+
19+
In the [Faable Dashboard](https://dashboard.faable.com), create a **Client** for your SPA and configure:
20+
21+
- **Allowed Callback URLs:** `http://localhost:4200/callback` (add your production URL later).
22+
- **Allowed Logout URLs:** `http://localhost:4200`.
23+
- **Allowed Web Origins:** `http://localhost:4200`.
24+
25+
Note your **auth domain** (`your-domain.auth.faable.link`) and **Client ID**. SPAs are public clients — no client secret is involved.
26+
27+
---
28+
29+
## 🛠️ Step 1: Create the App and Install
30+
31+
```bash
32+
npm install -g @angular/cli
33+
ng new my-app
34+
cd my-app
35+
npm install @faable/auth-js
36+
```
37+
38+
Answer the `ng new` prompts however you like — this guide uses standalone components (the default since Angular 17) and the Angular Router.
39+
40+
## Step 2: Create the Auth Service
41+
42+
The whole integration lives in one root-provided service. It creates the client once, seeds the session with `getSession()`, and keeps a `session` **signal** in sync with `onAuthStateChange` — login, logout, token refresh, or a change in another tab.
43+
44+
```ts
45+
// src/app/auth.service.ts
46+
import { Injectable, signal, computed } from "@angular/core";
47+
import { createClient, type Session } from "@faable/auth-js";
48+
49+
@Injectable({ providedIn: "root" })
50+
export class AuthService {
51+
private auth = createClient({
52+
domain: "your-domain.auth.faable.link",
53+
clientId: "YOUR_CLIENT_ID",
54+
redirectUri: window.location.origin + "/callback",
55+
});
56+
57+
/** Current session, reactive. `null` when signed out. */
58+
readonly session = signal<Session | null>(null);
59+
60+
/** Convenience: the signed-in user, or `undefined`. */
61+
readonly user = computed(() => this.session()?.user);
62+
63+
constructor() {
64+
// Keep the signal in sync with every auth change.
65+
this.auth.onAuthStateChange((_event, session) => this.session.set(session));
66+
67+
// Seed the initial value (also auto-refreshes an expired session).
68+
this.auth.getSession().then(({ data }) => this.session.set(data.session));
69+
}
70+
71+
signIn() {
72+
// Universal Login with every connection you've enabled.
73+
return this.auth.signInWithOauthConnection({});
74+
}
75+
76+
signOut() {
77+
return this.auth.signOut({ returnTo: window.location.origin });
78+
}
79+
80+
/** Finish the login on the /callback route. */
81+
handleCallback() {
82+
return this.auth.handleRedirectCallback();
83+
}
84+
85+
/** A fresh access token for API calls (auto-refreshed). */
86+
async accessToken() {
87+
const { data } = await this.auth.getSession();
88+
return data.session?.access_token ?? null;
89+
}
90+
}
91+
```
92+
93+
The client initializes itself on creation: it recovers an existing session from storage, or — on the callback URL — exchanges the PKCE `?code=` for tokens.
94+
95+
## Step 3: Wire It Up
96+
97+
There's no extra wiring step — because `AuthService` is `providedIn: "root"`, Angular constructs it lazily the first time you inject it, and the constructor above subscribes to `onAuthStateChange` and seeds the `session` signal. Every component that injects `AuthService` reads the same live signal.
98+
99+
- `signInWithOauthConnection({})` sends the user to your tenant's Universal Login with every [connection](../connections.md) you've enabled. Target one directly with `{ connection_id: "connection_..." }`.
100+
- `signOut({ returnTo })` clears the local session **and** the SSO cookie on the auth server. `returnTo` must be in **Allowed Logout URLs**.
101+
- Both methods redirect the browser on success — the promise intentionally never resolves, so don't put code after the `await`.
102+
103+
## Step 4: Login, User, and Logout
104+
105+
Inject the service and read the signal in the template with the new control flow (`@if`):
106+
107+
```ts
108+
// src/app/app.component.ts
109+
import { Component, inject } from "@angular/core";
110+
import { RouterOutlet } from "@angular/router";
111+
import { AuthService } from "./auth.service";
112+
113+
@Component({
114+
selector: "app-root",
115+
standalone: true,
116+
imports: [RouterOutlet],
117+
template: `
118+
@if (!auth.session()) {
119+
<button (click)="auth.signIn()">Sign in</button>
120+
} @else {
121+
<p>Hello {{ auth.user()?.email }}</p>
122+
<button (click)="auth.signOut()">Sign out</button>
123+
}
124+
125+
<router-outlet />
126+
`,
127+
})
128+
export class AppComponent {
129+
auth = inject(AuthService);
130+
}
131+
```
132+
133+
Signals make this reactive with zero boilerplate: when `onAuthStateChange` updates `session`, the template re-renders. No `async` pipe, no manual subscription cleanup.
134+
135+
## Step 5: The Callback Route
136+
137+
Add a `callback` route that runs `handleRedirectCallback()` once, then navigates home. It's idempotent — the client already started the exchange — and returns `{ error, returnTo }`, so deep links survive the login round-trip if you passed `returnTo` to `signInWithOauthConnection({ returnTo })`.
138+
139+
```ts
140+
// src/app/callback.component.ts
141+
import { Component, inject, signal, OnInit } from "@angular/core";
142+
import { Router } from "@angular/router";
143+
import { AuthService } from "./auth.service";
144+
145+
@Component({
146+
selector: "app-callback",
147+
standalone: true,
148+
template: `<p>{{ message() }}</p>`,
149+
})
150+
export class CallbackComponent implements OnInit {
151+
private auth = inject(AuthService);
152+
private router = inject(Router);
153+
message = signal("Signing you in…");
154+
155+
async ngOnInit() {
156+
const { error, returnTo } = await this.auth.handleCallback();
157+
if (error) this.message.set(error.message);
158+
else this.router.navigateByUrl(returnTo ?? "/");
159+
}
160+
}
161+
```
162+
163+
Register the route and bootstrap the app:
164+
165+
```ts
166+
// src/app/app.routes.ts
167+
import { Routes } from "@angular/router";
168+
import { CallbackComponent } from "./callback.component";
169+
170+
export const routes: Routes = [
171+
{ path: "callback", component: CallbackComponent },
172+
];
173+
```
174+
175+
```ts
176+
// src/main.ts
177+
import { bootstrapApplication } from "@angular/platform-browser";
178+
import { provideRouter } from "@angular/router";
179+
import { AppComponent } from "./app/app.component";
180+
import { routes } from "./app/app.routes";
181+
182+
bootstrapApplication(AppComponent, {
183+
providers: [provideRouter(routes)],
184+
});
185+
```
186+
187+
## Step 6: Call Your API
188+
189+
Read the token fresh before each call — `AuthService.accessToken()` wraps `getSession()`, which auto-refreshes an expired session:
190+
191+
```ts
192+
// src/app/api.service.ts
193+
import { Injectable, inject } from "@angular/core";
194+
import { AuthService } from "./auth.service";
195+
196+
@Injectable({ providedIn: "root" })
197+
export class ApiService {
198+
private auth = inject(AuthService);
199+
200+
async fetch(path: string) {
201+
const token = await this.auth.accessToken();
202+
if (!token) throw new Error("Not signed in");
203+
204+
return fetch(`https://api.myapp.com${path}`, {
205+
headers: { authorization: `Bearer ${token}` },
206+
});
207+
}
208+
}
209+
```
210+
211+
If your backend validates the token's `audience`, pass your [API](../apis.md) identifier when creating the client (`createClient({ ..., audience: "https://api.myapp.com" })`) — and see [Validate Access Tokens](../validate-access-tokens.md) for the Express middleware on the other side.
212+
213+
> **Optional:** to attach the token to every `HttpClient` request instead, register a functional [HTTP interceptor](https://angular.dev/guide/http/interceptors) that reads `AuthService.accessToken()` and sets the `Authorization` header. The `fetch` service above keeps the example dependency-free.
214+
215+
---
216+
217+
## ❓ FAQ
218+
219+
### Why expose the session as a signal instead of an Observable?
220+
221+
Signals are the modern Angular primitive for reactive state: they read like a plain value in templates (`auth.session()`), need no `async` pipe, and never leak subscriptions. `onAuthStateChange` pushes each change straight into `session.set(...)`. If you prefer RxJS, wrap the signal with `toObservable()`.
222+
223+
### How do I get the access token?
224+
225+
From the session: `const { data } = await auth.getSession()`, then `data.session?.access_token`. The `accessToken()` helper on `AuthService` does exactly this. There is no separate `getAccessToken()` — `getSession()` already refreshes expired tokens before returning.
226+
227+
### How do users pick a login method?
228+
229+
By default they choose on the Universal Login screen among the [connections](../connections.md) enabled for your client. To skip the screen and go straight to one provider, pass `connection_id` to `signInWithOauthConnection` — e.g. `{ connection_id: "connection_..." }`.
230+
231+
### Why doesn't the sign-in promise resolve?
232+
233+
`signInWithOauthConnection` and `signOut` redirect the browser on success, so the page unloads before the promise settles. Let the `onAuthStateChange` subscription update the `session` signal — don't put post-login logic after the `await`.
234+
235+
---
236+
237+
## 🔗 Related
238+
239+
- **[JavaScript](javascript.md)** · **[Vue](vue.md)** · **[SvelteKit](sveltekit.md)** — the same pattern in a framework.
240+
- **[React Quickstart](react.md)** · **[Next.js Quickstart](nextjs.md)** — with the React helper hooks.
241+
- **[Authorization Code Flow with PKCE](../oauth-flows/authorization-code.md)** — what the SDK does under the hood.
242+
- **[Validate Access Tokens](../validate-access-tokens.md)** — verify these tokens in your backend.
243+
- **[Connections](../connections.md)** — enable Google, GitHub, passwordless, and more.

0 commit comments

Comments
 (0)