diff --git a/.changeset/tricky-spoons-pay.md b/.changeset/tricky-spoons-pay.md
new file mode 100644
index 000000000..3cfbc18dc
--- /dev/null
+++ b/.changeset/tricky-spoons-pay.md
@@ -0,0 +1,7 @@
+---
+"@logto/js": minor
+---
+
+add `buildAngularAuthConfig()` to build `angular-auth-oidc-client` config in a Logto way
+
+Also add a new type `LogtoAngularConfig`.
diff --git a/.gitignore b/.gitignore
index 20fd7deb1..877706d51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@ node_modules
/packages/*/dist
/packages/*/lib
/packages/*/build
+/packages/*/tmp
+/packages/*/out-tsc
+/packages/*/bazel-out
# logs
logs
@@ -22,13 +25,16 @@ yarn-error.log*
.pnpm-debug.log*
# misc
+.angular/cache
+.sass-cache/
cache
.*cache
.DS_Store
+Thumbs.db
*.env
.idea/
*.pem
.history
.vercel
.next
-*.local
\ No newline at end of file
+*.local
diff --git a/packages/angular-sample/README.md b/packages/angular-sample/README.md
new file mode 100644
index 000000000..48750a5f9
--- /dev/null
+++ b/packages/angular-sample/README.md
@@ -0,0 +1,35 @@
+# Logto Angular sample
+
+A sample Angular application that demonstrates how to integrate Logto with `angular-auth-oidc-client`.
+
+**Configuration**: See [app.config.ts](src/app/app.config.ts).
+**Usage**: See [app.component.ts](src/app/app.component.ts).
+
+For more information about `angular-auth-oidc-client`, see its [repository](https://github.com/damienbod/angular-auth-oidc-client) and official [documentation](https://angular-auth-oidc-client.com/).
+
+> *[!Note]
+> This project is excluded from the workspace. To run the sample, you need to manually install project dependencies.
+
+---
+
+This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 17.2.0.
+
+## Development server
+
+Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The application will automatically reload if you change any of the source files.
+
+## Code scaffolding
+
+Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
+
+## Build
+
+Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory.
+
+## Running unit tests
+
+Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
+
+## Running end-to-end tests
+
+Run `ng e2e` to execute the end-to-end tests via a platform of your choice. To use this command, you need to first add a package that implements end-to-end testing capabilities.
diff --git a/packages/angular-sample/angular.json b/packages/angular-sample/angular.json
new file mode 100644
index 000000000..f3cf9e5f8
--- /dev/null
+++ b/packages/angular-sample/angular.json
@@ -0,0 +1,106 @@
+{
+ "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+ "version": 1,
+ "newProjectRoot": "projects",
+ "projects": {
+ "@logto/angular-sample": {
+ "projectType": "application",
+ "schematics": {
+ "@schematics/angular:component": {
+ "style": "scss"
+ }
+ },
+ "root": "",
+ "sourceRoot": "src",
+ "prefix": "app",
+ "architect": {
+ "build": {
+ "builder": "@angular-devkit/build-angular:application",
+ "options": {
+ "outputPath": "dist/logto/angular-sample",
+ "index": "src/index.html",
+ "browser": "src/main.ts",
+ "polyfills": [
+ "zone.js"
+ ],
+ "tsConfig": "tsconfig.app.json",
+ "inlineStyleLanguage": "scss",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets"
+ ],
+ "styles": [
+ "src/styles.scss"
+ ],
+ "scripts": [],
+ "server": "src/main.server.ts",
+ "prerender": true,
+ "ssr": {
+ "entry": "server.ts"
+ }
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kb",
+ "maximumError": "1mb"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "2kb",
+ "maximumError": "4kb"
+ }
+ ],
+ "outputHashing": "all"
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "builder": "@angular-devkit/build-angular:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "@logto/angular-sample:build:production"
+ },
+ "development": {
+ "buildTarget": "@logto/angular-sample:build:development"
+ }
+ },
+ "defaultConfiguration": "development"
+ },
+ "extract-i18n": {
+ "builder": "@angular-devkit/build-angular:extract-i18n",
+ "options": {
+ "buildTarget": "@logto/angular-sample:build"
+ }
+ },
+ "test": {
+ "builder": "@angular-devkit/build-angular:karma",
+ "options": {
+ "polyfills": [
+ "zone.js",
+ "zone.js/testing"
+ ],
+ "tsConfig": "tsconfig.spec.json",
+ "inlineStyleLanguage": "scss",
+ "assets": [
+ "src/favicon.ico",
+ "src/assets"
+ ],
+ "styles": [
+ "src/styles.scss"
+ ],
+ "scripts": []
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/angular-sample/package.json b/packages/angular-sample/package.json
new file mode 100644
index 000000000..686ac041d
--- /dev/null
+++ b/packages/angular-sample/package.json
@@ -0,0 +1,46 @@
+{
+ "name": "@logto/angular-sample",
+ "version": "0.0.0",
+ "scripts": {
+ "ng": "ng",
+ "start": "ng serve",
+ "build": "ng build",
+ "watch": "ng build --watch --configuration development",
+ "test": "ng test",
+ "serve:ssr:@logto/angular-sample": "node dist/logto/angular-sample/server/server.mjs"
+ },
+ "private": true,
+ "dependencies": {
+ "@angular/animations": "^17.2.0",
+ "@angular/common": "^17.2.0",
+ "@angular/compiler": "^17.2.0",
+ "@angular/core": "^17.2.0",
+ "@angular/forms": "^17.2.0",
+ "@angular/platform-browser": "^17.2.0",
+ "@angular/platform-browser-dynamic": "^17.2.0",
+ "@angular/platform-server": "^17.2.0",
+ "@angular/router": "^17.2.0",
+ "@angular/ssr": "^17.2.0",
+ "@logto/js": "workspace:^",
+ "angular-auth-oidc-client": "^17.0.0",
+ "express": "^4.18.2",
+ "rxjs": "~7.8.0",
+ "tslib": "^2.3.0",
+ "zone.js": "~0.14.3"
+ },
+ "devDependencies": {
+ "@angular-devkit/build-angular": "^17.2.0",
+ "@angular/cli": "^17.2.0",
+ "@angular/compiler-cli": "^17.2.0",
+ "@types/express": "^4.17.17",
+ "@types/jasmine": "~5.1.0",
+ "@types/node": "^18.18.0",
+ "jasmine-core": "~5.1.0",
+ "karma": "~6.4.0",
+ "karma-chrome-launcher": "~3.2.0",
+ "karma-coverage": "~2.2.0",
+ "karma-jasmine": "~5.1.0",
+ "karma-jasmine-html-reporter": "~2.1.0",
+ "typescript": "~5.3.2"
+ }
+}
\ No newline at end of file
diff --git a/packages/angular-sample/server.ts b/packages/angular-sample/server.ts
new file mode 100644
index 000000000..7083b14fe
--- /dev/null
+++ b/packages/angular-sample/server.ts
@@ -0,0 +1,56 @@
+import { APP_BASE_HREF } from '@angular/common';
+import { CommonEngine } from '@angular/ssr';
+import express from 'express';
+import { fileURLToPath } from 'node:url';
+import { dirname, join, resolve } from 'node:path';
+import bootstrap from './src/main.server';
+
+// The Express app is exported so that it can be used by serverless Functions.
+export function app(): express.Express {
+ const server = express();
+ const serverDistFolder = dirname(fileURLToPath(import.meta.url));
+ const browserDistFolder = resolve(serverDistFolder, '../browser');
+ const indexHtml = join(serverDistFolder, 'index.server.html');
+
+ const commonEngine = new CommonEngine();
+
+ server.set('view engine', 'html');
+ server.set('views', browserDistFolder);
+
+ // Example Express Rest API endpoints
+ // server.get('/api/**', (req, res) => { });
+ // Serve static files from /browser
+ server.get('*.*', express.static(browserDistFolder, {
+ maxAge: '1y'
+ }));
+
+ // All regular routes use the Angular engine
+ server.get('*', (req, res, next) => {
+ const { protocol, originalUrl, baseUrl, headers } = req;
+
+ commonEngine
+ .render({
+ bootstrap,
+ documentFilePath: indexHtml,
+ url: `${protocol}://${headers.host}${originalUrl}`,
+ publicPath: browserDistFolder,
+ providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
+ })
+ .then((html) => res.send(html))
+ .catch((err) => next(err));
+ });
+
+ return server;
+}
+
+function run(): void {
+ const port = process.env['PORT'] || 4000;
+
+ // Start up the Node server
+ const server = app();
+ server.listen(port, () => {
+ console.log(`Node Express server listening on http://localhost:${port}`);
+ });
+}
+
+run();
diff --git a/packages/angular-sample/src/app/app.component.html b/packages/angular-sample/src/app/app.component.html
new file mode 100644
index 000000000..f32ed256e
--- /dev/null
+++ b/packages/angular-sample/src/app/app.component.html
@@ -0,0 +1,9 @@
+
Hello, {{ title }}
+Congratulations! Your app is running. 🎉
+
+
+ {{ userData | json }}
+ Access token: {{ accessToken }}
+
+
+
diff --git a/packages/angular-sample/src/app/app.component.scss b/packages/angular-sample/src/app/app.component.scss
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/angular-sample/src/app/app.component.spec.ts b/packages/angular-sample/src/app/app.component.spec.ts
new file mode 100644
index 000000000..535e602b4
--- /dev/null
+++ b/packages/angular-sample/src/app/app.component.spec.ts
@@ -0,0 +1,29 @@
+import { TestBed } from '@angular/core/testing';
+import { AppComponent } from './app.component';
+
+describe('AppComponent', () => {
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [AppComponent],
+ }).compileComponents();
+ });
+
+ it('should create the app', () => {
+ const fixture = TestBed.createComponent(AppComponent);
+ const app = fixture.componentInstance;
+ expect(app).toBeTruthy();
+ });
+
+ it(`should have the '@logto/angular-sample' title`, () => {
+ const fixture = TestBed.createComponent(AppComponent);
+ const app = fixture.componentInstance;
+ expect(app.title).toEqual('@logto/angular-sample');
+ });
+
+ it('should render title', () => {
+ const fixture = TestBed.createComponent(AppComponent);
+ fixture.detectChanges();
+ const compiled = fixture.nativeElement as HTMLElement;
+ expect(compiled.querySelector('h1')?.textContent).toContain('Hello, @logto/angular-sample');
+ });
+});
diff --git a/packages/angular-sample/src/app/app.component.ts b/packages/angular-sample/src/app/app.component.ts
new file mode 100644
index 000000000..741d0ac69
--- /dev/null
+++ b/packages/angular-sample/src/app/app.component.ts
@@ -0,0 +1,43 @@
+import { Component, OnInit } from '@angular/core';
+import { RouterOutlet } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import { OidcSecurityService } from 'angular-auth-oidc-client';
+import type { UserInfoResponse } from '@logto/js';
+
+@Component({
+ selector: 'app-root',
+ standalone: true,
+ imports: [RouterOutlet, CommonModule],
+ templateUrl: './app.component.html',
+ styleUrl: './app.component.scss'
+})
+export class AppComponent implements OnInit {
+ title = '@logto/angular-sample';
+ isAuthenticated = false;
+ userData?: UserInfoResponse;
+ idToken?: string;
+ accessToken?: string;
+
+ constructor(public oidcSecurityService: OidcSecurityService) { }
+
+ ngOnInit() {
+ this.oidcSecurityService.checkAuth().subscribe(({ isAuthenticated, userData, idToken, accessToken }) => {
+ console.log('app authenticated', isAuthenticated, userData);
+ this.isAuthenticated = isAuthenticated;
+ this.userData = userData;
+ this.idToken = idToken;
+ this.accessToken = accessToken;
+ });
+ }
+
+ signIn() {
+ this.oidcSecurityService.authorize();
+ }
+
+ signOut() {
+ this.oidcSecurityService.logoff().subscribe((result) => {
+ console.log('app sign-out', result);
+ this.isAuthenticated = false;
+ });
+ }
+}
diff --git a/packages/angular-sample/src/app/app.config.server.ts b/packages/angular-sample/src/app/app.config.server.ts
new file mode 100644
index 000000000..b4d57c942
--- /dev/null
+++ b/packages/angular-sample/src/app/app.config.server.ts
@@ -0,0 +1,11 @@
+import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
+import { provideServerRendering } from '@angular/platform-server';
+import { appConfig } from './app.config';
+
+const serverConfig: ApplicationConfig = {
+ providers: [
+ provideServerRendering()
+ ]
+};
+
+export const config = mergeApplicationConfig(appConfig, serverConfig);
diff --git a/packages/angular-sample/src/app/app.config.ts b/packages/angular-sample/src/app/app.config.ts
new file mode 100644
index 000000000..bdaaf1b06
--- /dev/null
+++ b/packages/angular-sample/src/app/app.config.ts
@@ -0,0 +1,27 @@
+import { ApplicationConfig } from '@angular/core';
+import { provideRouter } from '@angular/router';
+import { provideAuth } from 'angular-auth-oidc-client';
+
+import { routes } from './app.routes';
+import { provideClientHydration } from '@angular/platform-browser';
+import { provideHttpClient, withFetch } from '@angular/common/http';
+import { UserScope, buildAngularAuthConfig } from '@logto/js';
+
+export const appConfig: ApplicationConfig = {
+ providers: [
+ provideHttpClient(withFetch()),
+ provideAuth({
+ config: buildAngularAuthConfig({
+ endpoint: '',
+ appId: '',
+ scopes: [UserScope.Email], // Replace with your scopes
+ redirectUri: '',
+ postLogoutRedirectUri: '',
+ // See https://docs.logto.io/sdk/angular/ for more information
+ // resource: 'https://default.logto.app/api'
+ })
+ }),
+ provideRouter(routes),
+ provideClientHydration()
+ ]
+};
diff --git a/packages/angular-sample/src/app/app.routes.ts b/packages/angular-sample/src/app/app.routes.ts
new file mode 100644
index 000000000..dc39edb5f
--- /dev/null
+++ b/packages/angular-sample/src/app/app.routes.ts
@@ -0,0 +1,3 @@
+import { Routes } from '@angular/router';
+
+export const routes: Routes = [];
diff --git a/packages/angular-sample/src/assets/.gitkeep b/packages/angular-sample/src/assets/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/packages/angular-sample/src/favicon.ico b/packages/angular-sample/src/favicon.ico
new file mode 100644
index 000000000..57614f9c9
Binary files /dev/null and b/packages/angular-sample/src/favicon.ico differ
diff --git a/packages/angular-sample/src/index.html b/packages/angular-sample/src/index.html
new file mode 100644
index 000000000..a95e5ca0a
--- /dev/null
+++ b/packages/angular-sample/src/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+ @logto/angularSample
+
+
+
+
+
+
+
+
diff --git a/packages/angular-sample/src/main.server.ts b/packages/angular-sample/src/main.server.ts
new file mode 100644
index 000000000..4b9d4d154
--- /dev/null
+++ b/packages/angular-sample/src/main.server.ts
@@ -0,0 +1,7 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { AppComponent } from './app/app.component';
+import { config } from './app/app.config.server';
+
+const bootstrap = () => bootstrapApplication(AppComponent, config);
+
+export default bootstrap;
diff --git a/packages/angular-sample/src/main.ts b/packages/angular-sample/src/main.ts
new file mode 100644
index 000000000..35b00f346
--- /dev/null
+++ b/packages/angular-sample/src/main.ts
@@ -0,0 +1,6 @@
+import { bootstrapApplication } from '@angular/platform-browser';
+import { appConfig } from './app/app.config';
+import { AppComponent } from './app/app.component';
+
+bootstrapApplication(AppComponent, appConfig)
+ .catch((err) => console.error(err));
diff --git a/packages/angular-sample/src/styles.scss b/packages/angular-sample/src/styles.scss
new file mode 100644
index 000000000..90d4ee007
--- /dev/null
+++ b/packages/angular-sample/src/styles.scss
@@ -0,0 +1 @@
+/* You can add global styles to this file, and also import other style files */
diff --git a/packages/angular-sample/tsconfig.app.json b/packages/angular-sample/tsconfig.app.json
new file mode 100644
index 000000000..7dc7284f6
--- /dev/null
+++ b/packages/angular-sample/tsconfig.app.json
@@ -0,0 +1,18 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/app",
+ "types": [
+ "node"
+ ]
+ },
+ "files": [
+ "src/main.ts",
+ "src/main.server.ts",
+ "server.ts"
+ ],
+ "include": [
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/packages/angular-sample/tsconfig.json b/packages/angular-sample/tsconfig.json
new file mode 100644
index 000000000..f37b67ff0
--- /dev/null
+++ b/packages/angular-sample/tsconfig.json
@@ -0,0 +1,33 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "compileOnSave": false,
+ "compilerOptions": {
+ "outDir": "./dist/out-tsc",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "sourceMap": true,
+ "declaration": false,
+ "experimentalDecorators": true,
+ "moduleResolution": "node",
+ "importHelpers": true,
+ "target": "ES2022",
+ "module": "ES2022",
+ "useDefineForClassFields": false,
+ "lib": [
+ "ES2022",
+ "dom"
+ ]
+ },
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/packages/angular-sample/tsconfig.spec.json b/packages/angular-sample/tsconfig.spec.json
new file mode 100644
index 000000000..be7e9da76
--- /dev/null
+++ b/packages/angular-sample/tsconfig.spec.json
@@ -0,0 +1,14 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "./out-tsc/spec",
+ "types": [
+ "jasmine"
+ ]
+ },
+ "include": [
+ "src/**/*.spec.ts",
+ "src/**/*.d.ts"
+ ]
+}
diff --git a/packages/js/package.json b/packages/js/package.json
index d1aa0dd79..0810a8cde 100644
--- a/packages/js/package.json
+++ b/packages/js/package.json
@@ -41,6 +41,7 @@
"@swc/jest": "^0.2.24",
"@types/jest": "^29.5.1",
"@types/node": "^20.11.19",
+ "angular-auth-oidc-client": "^17.0.0",
"eslint": "^8.44.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
diff --git a/packages/js/src/core/user-info.test.ts b/packages/js/src/core/user-info.test.ts
index e08552759..4a2716dfc 100644
--- a/packages/js/src/core/user-info.test.ts
+++ b/packages/js/src/core/user-info.test.ts
@@ -3,7 +3,13 @@ import { fetchUserInfo } from './user-info.js';
describe('fetchUserInfo', () => {
test('should return UserInfoResponse', async () => {
- const userInfoResponse: UserInfoResponse = { sub: 'foo' };
+ const userInfoResponse: UserInfoResponse = {
+ sub: 'foo',
+ iss: 'bar',
+ aud: 'baz',
+ iat: 123,
+ exp: 123,
+ };
const fetchFunction = jest.fn().mockResolvedValue(userInfoResponse);
await expect(
fetchUserInfo('https://example.com/oidc/me', 'access_token_value', fetchFunction)
diff --git a/packages/js/src/core/user-info.ts b/packages/js/src/core/user-info.ts
index cd4db7be4..100f0cf8f 100644
--- a/packages/js/src/core/user-info.ts
+++ b/packages/js/src/core/user-info.ts
@@ -1,5 +1,4 @@
-import type { Nullable } from '@silverhand/essentials';
-
+import { type IdTokenClaims } from '../index.js';
import type { Requester } from '../types/index.js';
type Identity = {
@@ -7,17 +6,9 @@ type Identity = {
details?: Record;
};
-export type UserInfoResponse = {
- sub: string;
- name?: Nullable;
- username?: Nullable;
- picture?: Nullable;
- email?: Nullable;
- email_verified?: boolean;
- phone_number?: Nullable;
- phone_number_verified?: boolean;
- custom_data?: unknown; // Not null in DB.
- identities?: Record; // Not null in DB.
+export type UserInfoResponse = IdTokenClaims & {
+ custom_data?: unknown;
+ identities?: Record;
};
export const fetchUserInfo = async (
diff --git a/packages/js/src/utils/angular.ts b/packages/js/src/utils/angular.ts
new file mode 100644
index 000000000..c699c24a2
--- /dev/null
+++ b/packages/js/src/utils/angular.ts
@@ -0,0 +1,110 @@
+import { type OpenIdConfiguration } from 'angular-auth-oidc-client';
+
+import { Prompt } from '../consts/index.js';
+
+import { withDefaultScopes } from './scopes.js';
+
+/** The Logto configuration object for Angular apps. */
+export type LogtoAngularConfig = {
+ /**
+ * The endpoint for the Logto server, you can get it from the integration guide
+ * or the team settings page of the Logto Console.
+ *
+ * @example https://foo.logto.app
+ */
+ endpoint: string;
+ /**
+ * The client ID of your application, you can get it from the integration guide
+ * or the application details page of the Logto Console.
+ */
+ appId: string;
+ /**
+ * The scopes (permissions) that your application needs to access.
+ * Scopes that will be added by default: `openid`, `offline_access` and `profile`.
+ */
+ scopes?: string[];
+ /**
+ * The API resource that your application needs to access.
+ *
+ * @see {@link https://docs.logto.io/docs/recipes/rbac/ | RBAC} to learn more about how to use
+ * role-based access control (RBAC) to protect API resources.
+ */
+ resource?: string;
+ /**
+ * @param redirectUri The redirect URI that the user will be redirected to after the sign-in flow
+ * is completed.
+ */
+ redirectUri: string;
+ /**
+ * @param postLogoutRedirectUri The URI that the user will be redirected to after the sign-out
+ * flow is completed.
+ */
+ postLogoutRedirectUri?: string;
+ /**
+ * The prompt parameter to be used for the authorization request.
+ *
+ * @default Prompt.Consent
+ */
+ prompt?: Prompt | Prompt[];
+};
+
+/**
+ * A helper function to build the OpenID Connect configuration for `angular-auth-oidc-client`
+ * using a Logto-friendly way.
+ *
+ * @example
+ * ```ts
+ * // A minimal example
+ * import { buildAngularAuthConfig } from '@logto/js';
+ * import { provideAuth } from 'angular-auth-oidc-client';
+ *
+ * provideAuth({
+ * config: buildAngularAuthConfig({
+ * endpoint: '',
+ * appId: '',
+ * redirectUri: '',
+ * }),
+ * });
+ * ```
+ *
+ * @param logtoConfig The Logto configuration object for Angular apps.
+ * @returns The OpenID Connect configuration for `angular-auth-oidc-client`.
+ * @see {@link https://angular-auth-oidc-client.com/ | angular-auth-oidc-client} to learn more
+ * about how to use the library.
+ */
+export const buildAngularAuthConfig = (logtoConfig: LogtoAngularConfig): OpenIdConfiguration => {
+ const {
+ endpoint,
+ appId: clientId,
+ scopes,
+ resource,
+ redirectUri: redirectUrl,
+ postLogoutRedirectUri,
+ prompt = Prompt.Consent,
+ } = logtoConfig;
+ const scope = withDefaultScopes(scopes);
+ const customParameters = resource ? { resource } : undefined;
+
+ return {
+ authority: new URL('/oidc', endpoint).href,
+ redirectUrl,
+ postLogoutRedirectUri,
+ clientId,
+ scope,
+ responseType: 'code',
+ autoUserInfo: !resource,
+ renewUserInfoAfterTokenRenew: !resource,
+ silentRenew: true,
+ useRefreshToken: true,
+ customParamsAuthRequest: {
+ prompt: Array.isArray(prompt) ? prompt.join(' ') : prompt,
+ ...customParameters,
+ },
+ customParamsCodeRequest: {
+ ...customParameters,
+ },
+ customParamsRefreshTokenRequest: {
+ ...customParameters,
+ },
+ };
+};
diff --git a/packages/js/src/utils/index.ts b/packages/js/src/utils/index.ts
index b62ee6930..67933b790 100644
--- a/packages/js/src/utils/index.ts
+++ b/packages/js/src/utils/index.ts
@@ -4,3 +4,4 @@ export * from './id-token.js';
export * from './access-token.js';
export * from './scopes.js';
export * from './arbitrary-object.js';
+export * from './angular.js';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cd538f11c..85b6b5179 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -411,6 +411,9 @@ importers:
'@types/node':
specifier: ^20.11.19
version: 20.11.19
+ angular-auth-oidc-client:
+ specifier: ^17.0.0
+ version: 17.0.0(@angular/common@17.2.1)(@angular/core@17.2.1)(@angular/router@17.2.1)(rxjs@7.8.1)
eslint:
specifier: ^8.44.0
version: 8.44.0
@@ -1123,6 +1126,62 @@ packages:
dependencies:
'@jridgewell/trace-mapping': 0.3.17
+ /@angular/common@17.2.1(@angular/core@17.2.1)(rxjs@7.8.1):
+ resolution: {integrity: sha512-ZkQwvjJhnqKulJn3kwbnodYvQf8g8hy2FUMB2MRLXKgwLPv9iqF/KRgSwcNIZnq8hyvIr6FmAntMdyCOonykDQ==}
+ engines: {node: ^18.13.0 || >=20.9.0}
+ peerDependencies:
+ '@angular/core': 17.2.1
+ rxjs: ^6.5.3 || ^7.4.0
+ dependencies:
+ '@angular/core': 17.2.1(rxjs@7.8.1)(zone.js@0.14.4)
+ rxjs: 7.8.1
+ tslib: 2.5.0
+ dev: true
+
+ /@angular/core@17.2.1(rxjs@7.8.1)(zone.js@0.14.4):
+ resolution: {integrity: sha512-gfWeskXA8RA0D3WOPBV5wT8RpqtqFhB8OCR8diGfLojqbMrmZXEvxALBHKAgfarWcR1rnRgmjCQKejWLWCLmmg==}
+ engines: {node: ^18.13.0 || >=20.9.0}
+ peerDependencies:
+ rxjs: ^6.5.3 || ^7.4.0
+ zone.js: ~0.14.0
+ dependencies:
+ rxjs: 7.8.1
+ tslib: 2.5.0
+ zone.js: 0.14.4
+ dev: true
+
+ /@angular/platform-browser@17.2.1(@angular/common@17.2.1)(@angular/core@17.2.1):
+ resolution: {integrity: sha512-on+fTZiDTBJmRQbQe6GOClqaUFe4GJdLS1EbmI+6/8Ntv4QW2PowWnaxajoqTj2Zrh22J9DSNy7RWcrQDdyU3g==}
+ engines: {node: ^18.13.0 || >=20.9.0}
+ peerDependencies:
+ '@angular/animations': 17.2.1
+ '@angular/common': 17.2.1
+ '@angular/core': 17.2.1
+ peerDependenciesMeta:
+ '@angular/animations':
+ optional: true
+ dependencies:
+ '@angular/common': 17.2.1(@angular/core@17.2.1)(rxjs@7.8.1)
+ '@angular/core': 17.2.1(rxjs@7.8.1)(zone.js@0.14.4)
+ tslib: 2.5.0
+ dev: true
+
+ /@angular/router@17.2.1(@angular/common@17.2.1)(@angular/core@17.2.1)(@angular/platform-browser@17.2.1)(rxjs@7.8.1):
+ resolution: {integrity: sha512-sJFraoPTHV09jZQV3XcFHRJsY7EAuXcBn5k+7GGye60YgTXAjL3OC++Cuv4AScFYRp+IqbrE3I0tflsRtQzemw==}
+ engines: {node: ^18.13.0 || >=20.9.0}
+ peerDependencies:
+ '@angular/common': 17.2.1
+ '@angular/core': 17.2.1
+ '@angular/platform-browser': 17.2.1
+ rxjs: ^6.5.3 || ^7.4.0
+ dependencies:
+ '@angular/common': 17.2.1(@angular/core@17.2.1)(rxjs@7.8.1)
+ '@angular/core': 17.2.1(rxjs@7.8.1)(zone.js@0.14.4)
+ '@angular/platform-browser': 17.2.1(@angular/common@17.2.1)(@angular/core@17.2.1)
+ rxjs: 7.8.1
+ tslib: 2.5.0
+ dev: true
+
/@babel/code-frame@7.21.4:
resolution: {integrity: sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==}
engines: {node: '>=6.9.0'}
@@ -5306,6 +5365,22 @@ packages:
uri-js: 4.4.1
dev: true
+ /angular-auth-oidc-client@17.0.0(@angular/common@17.2.1)(@angular/core@17.2.1)(@angular/router@17.2.1)(rxjs@7.8.1):
+ resolution: {integrity: sha512-WjQDwMJ+XpoCTht5wn8XgR1MFW+IykDvEOZNQCbpxzNzR1312hv6nPSStYQA9Jtpj3T6fZFFQsn06jfFwDE7WQ==}
+ peerDependencies:
+ '@angular/common': '>=15.0.0'
+ '@angular/core': '>=15.0.0'
+ '@angular/router': '>=15.0.0'
+ rxjs: ^6.5.3 || ^7.4.0
+ dependencies:
+ '@angular/common': 17.2.1(@angular/core@17.2.1)(rxjs@7.8.1)
+ '@angular/core': 17.2.1(rxjs@7.8.1)(zone.js@0.14.4)
+ '@angular/router': 17.2.1(@angular/common@17.2.1)(@angular/core@17.2.1)(@angular/platform-browser@17.2.1)(rxjs@7.8.1)
+ rfc4648: 1.5.3
+ rxjs: 7.8.1
+ tslib: 2.5.0
+ dev: true
+
/ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
@@ -11487,6 +11562,10 @@ packages:
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
dev: true
+ /rfc4648@1.5.3:
+ resolution: {integrity: sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==}
+ dev: true
+
/rfdc@1.3.0:
resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==}
dev: true
@@ -11547,6 +11626,12 @@ packages:
queue-microtask: 1.2.3
dev: true
+ /rxjs@7.8.1:
+ resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==}
+ dependencies:
+ tslib: 2.5.0
+ dev: true
+
/safe-array-concat@1.0.1:
resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==}
engines: {node: '>=0.4'}
@@ -13182,3 +13267,9 @@ packages:
/zod@3.21.4:
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
+
+ /zone.js@0.14.4:
+ resolution: {integrity: sha512-NtTUvIlNELez7Q1DzKVIFZBzNb646boQMgpATo9z3Ftuu/gWvzxCW7jdjcUDoRGxRikrhVHB/zLXh1hxeJawvw==}
+ dependencies:
+ tslib: 2.5.0
+ dev: true
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 18ec407ef..81a5d3c6a 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,3 @@
packages:
- 'packages/*'
+ - '!packages/angular-sample' # Angular needs too many dependencies