Skip to content

Commit fca19fd

Browse files
Merge pull request #24026 from abpframework/feat/#23427
Schematic to Enable SSR in Existing ABP Angular Projects
2 parents e36613b + b96b170 commit fca19fd

File tree

25 files changed

+1405
-3
lines changed

25 files changed

+1405
-3
lines changed

npm/ng-packs/packages/oauth/src/lib/services/server-token-storage.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import { Inject, Injectable, Optional } from '@angular/core';
1+
import { inject, Inject, Injectable, Optional } from '@angular/core';
22
import { OAuthStorage } from 'angular-oauth2-oidc';
33
import { REQUEST } from '@angular/core';
4+
import { COOKIES } from '../tokens';
45

56
@Injectable({ providedIn: null })
67
export class ServerTokenStorageService implements OAuthStorage {
78
private cookies = new Map<string, string>();
9+
// For server builders where REQUEST injection is not possible, cookies can be provided via COOKIES token
10+
private cookiesStr = inject<string>(COOKIES, { optional: true });
811

912
constructor(@Optional() @Inject(REQUEST) private req: Request | null) {
10-
const cookieHeader = this.req?.headers.get('cookie') ?? '';
13+
const cookieHeader = this.req?.headers.get('cookie') ?? this.cookiesStr ?? '';
1114
for (const part of cookieHeader.split(';')) {
1215
const i = part.indexOf('=');
1316
if (i > -1) {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { InjectionToken } from '@angular/core';
2+
3+
export const COOKIES = new InjectionToken<string>('COOKIES');
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './auth-flow-strategy';
2+
export * from './cookies';

npm/ng-packs/packages/schematics/src/collection.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,17 @@
3434
"description": "ABP Change Styles of Theme Schematics",
3535
"factory": "./commands/change-theme",
3636
"schema": "./commands/change-theme/schema.json"
37-
37+
},
38+
"server": {
39+
"factory": "./commands/ssr-add/server",
40+
"description": "Create an Angular server app.",
41+
"schema": "./commands/ssr-add/server/schema.json",
42+
"hidden": true
43+
},
44+
"ssr-add": {
45+
"description": "ABP SSR Add Schematics",
46+
"factory": "./commands/ssr-add",
47+
"schema": "./commands/ssr-add/schema.json"
3848
}
3949
}
4050
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import {
2+
AngularNodeAppEngine,
3+
createNodeRequestHandler,
4+
isMainModule,
5+
writeResponseToNodeResponse,
6+
} from '@angular/ssr/node';
7+
import express from 'express';
8+
import { dirname, resolve } from 'node:path';
9+
import { fileURLToPath } from 'node:url';
10+
import {environment} from './environments/environment';
11+
import { ServerCookieParser } from '@abp/ng.core';
12+
13+
// ESM import
14+
import * as oidc from 'openid-client';
15+
16+
if (environment.production === false) {
17+
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
18+
}
19+
20+
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
21+
const browserDistFolder = resolve(serverDistFolder, '../browser');
22+
23+
const app = express();
24+
const angularApp = new AngularNodeAppEngine();
25+
26+
const ISSUER = new URL(environment.oAuthConfig.issuer);
27+
const CLIENT_ID = environment.oAuthConfig.clientId;
28+
const REDIRECT_URI = environment.oAuthConfig.redirectUri;
29+
const SCOPE = environment.oAuthConfig.scope;
30+
// @ts-ignore
31+
const CLIENT_SECRET = environment.oAuthConfig.clientSecret || undefined;
32+
33+
const config = await oidc.discovery(ISSUER, CLIENT_ID, CLIENT_SECRET);
34+
const secureCookie = { httpOnly: true, sameSite: 'lax' as const, secure: environment.production, path: '/' };
35+
const tokenCookie = { ...secureCookie, httpOnly: false };
36+
37+
app.use(ServerCookieParser.middleware());
38+
39+
const sessions = new Map<string, { pkce?: string; state?: string; refresh?: string; at?: string, returnUrl?: string }>();
40+
41+
app.get('/authorize', async (_req, res) => {
42+
const code_verifier = oidc.randomPKCECodeVerifier();
43+
const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier);
44+
const state = oidc.randomState();
45+
46+
if (_req.query.returnUrl) {
47+
const returnUrl = String(_req.query.returnUrl || null);
48+
res.cookie('returnUrl', returnUrl, { ...secureCookie, maxAge: 5 * 60 * 1000 });
49+
}
50+
51+
const sid = crypto.randomUUID();
52+
sessions.set(sid, { pkce: code_verifier, state });
53+
res.cookie('sid', sid, secureCookie);
54+
55+
const url = oidc.buildAuthorizationUrl(config, {
56+
redirect_uri: REDIRECT_URI,
57+
scope: SCOPE,
58+
code_challenge,
59+
code_challenge_method: 'S256',
60+
state,
61+
});
62+
res.redirect(url.toString());
63+
});
64+
65+
app.get('/logout', async (req, res) => {
66+
try {
67+
const sid = req.cookies.sid;
68+
69+
if (sid && sessions.has(sid)) {
70+
sessions.delete(sid);
71+
}
72+
73+
res.clearCookie('sid', secureCookie);
74+
res.clearCookie('access_token', tokenCookie);
75+
res.clearCookie('refresh_token', secureCookie);
76+
res.clearCookie('expires_at', tokenCookie);
77+
res.clearCookie('returnUrl', secureCookie);
78+
79+
const endSessionEndpoint = config.serverMetadata().end_session_endpoint;
80+
if (endSessionEndpoint) {
81+
const logoutUrl = new URL(endSessionEndpoint);
82+
logoutUrl.searchParams.set('post_logout_redirect_uri', REDIRECT_URI);
83+
logoutUrl.searchParams.set('client_id', CLIENT_ID);
84+
85+
return res.redirect(logoutUrl.toString());
86+
}
87+
res.redirect('/');
88+
89+
} catch (error) {
90+
console.error('Logout error:', error);
91+
res.status(500).send('Logout error');
92+
}
93+
});
94+
95+
app.get('/', async (req, res, next) => {
96+
try {
97+
const { code, state } = req.query as any;
98+
if (!code || !state) return next();
99+
100+
const sid = req.cookies.sid;
101+
const sess = sid && sessions.get(sid);
102+
if (!sess || state !== sess.state) return res.status(400).send('invalid state');
103+
104+
const tokenEndpoint = config.serverMetadata().token_endpoint!;
105+
const body = new URLSearchParams({
106+
grant_type: 'authorization_code',
107+
code: String(code),
108+
redirect_uri: environment.oAuthConfig.redirectUri,
109+
code_verifier: sess.pkce!,
110+
client_id: CLIENT_ID,
111+
client_secret: CLIENT_SECRET || ''
112+
});
113+
114+
const resp = await fetch(tokenEndpoint, {
115+
method: 'POST',
116+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
117+
body,
118+
});
119+
120+
if (!resp.ok) {
121+
const errTxt = await resp.text();
122+
console.error('token error:', resp.status, errTxt);
123+
return res.status(500).send('token error');
124+
}
125+
126+
const tokens = await resp.json();
127+
128+
const expiresInSec =
129+
Number(tokens.expires_in ?? tokens.expiresIn ?? 3600);
130+
const skewSec = 60;
131+
const accessExpiresAt = new Date(
132+
Date.now() + Math.max(0, expiresInSec - skewSec) * 1000
133+
);
134+
135+
sessions.set(sid, { ...sess, at: tokens.access_token, refresh: tokens.refresh_token });
136+
res.cookie('access_token', tokens.access_token, {...tokenCookie, maxAge: accessExpiresAt.getTime()});
137+
res.cookie('refresh_token', tokens.refresh_token, secureCookie);
138+
res.cookie('expires_at', String(accessExpiresAt.getTime()), tokenCookie);
139+
140+
const returnUrl = req.cookies?.returnUrl ?? '/';
141+
res.clearCookie('returnUrl', secureCookie);
142+
143+
return res.redirect(returnUrl);
144+
} catch (e) {
145+
console.error('OIDC error:', e);
146+
return res.status(500).send('oidc error');
147+
}
148+
});
149+
150+
/**
151+
* Serve static files from /browser
152+
*/
153+
app.use(
154+
express.static(browserDistFolder, {
155+
maxAge: '1y',
156+
index: false,
157+
redirect: false,
158+
}),
159+
);
160+
161+
/**
162+
* Handle all other requests by rendering the Angular application.
163+
*/
164+
app.use((req, res, next) => {
165+
angularApp
166+
.handle(req)
167+
.then(response => {
168+
if (response) {
169+
res.cookie('ssr-init', 'true', {...secureCookie, httpOnly: false});
170+
return writeResponseToNodeResponse(response, res);
171+
} else {
172+
return next()
173+
}
174+
})
175+
.catch(next);
176+
});
177+
178+
/**
179+
* Start the server if this module is the main entry point.
180+
* The server listens on the port defined by the `PORT` environment variable, or defaults to 4000.
181+
*/
182+
if (isMainModule(import.meta.url)) {
183+
const port = process.env['PORT'] || 4200;
184+
app.listen(port, () => {
185+
console.log(`Node Express server listening on http://localhost:${port}`);
186+
});
187+
}
188+
189+
/**
190+
* Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions.
191+
*/
192+
export const reqHandler = createNodeRequestHandler(app);

0 commit comments

Comments
 (0)