|
| 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