From 4b6ca43766633746f69d8aee171a0cf8ebc01c49 Mon Sep 17 00:00:00 2001 From: erdemcaygor Date: Tue, 19 Aug 2025 15:07:43 +0300 Subject: [PATCH] authentication code flow refactoring --- .../apps/dev-app/src/app/app.config.server.ts | 26 +++++- npm/ng-packs/apps/dev-app/src/server.ts | 36 ++++---- .../lib/services/cookie-storage.service.ts | 85 +++++++++++++++++++ .../packages/core/src/lib/services/index.ts | 1 + .../packages/core/src/lib/tokens/index.ts | 1 + .../core/src/lib/tokens/ssr-state.token.ts | 14 +++ .../src/lib/services/remember-me.service.ts | 81 +++++++++++------- 7 files changed, 190 insertions(+), 54 deletions(-) create mode 100644 npm/ng-packs/packages/core/src/lib/services/cookie-storage.service.ts create mode 100644 npm/ng-packs/packages/core/src/lib/tokens/ssr-state.token.ts diff --git a/npm/ng-packs/apps/dev-app/src/app/app.config.server.ts b/npm/ng-packs/apps/dev-app/src/app/app.config.server.ts index 886f4d767a..3059303b1d 100644 --- a/npm/ng-packs/apps/dev-app/src/app/app.config.server.ts +++ b/npm/ng-packs/apps/dev-app/src/app/app.config.server.ts @@ -1,11 +1,29 @@ -import { mergeApplicationConfig, ApplicationConfig } from '@angular/core'; +import { + mergeApplicationConfig, + ApplicationConfig, + provideAppInitializer, + inject, + PLATFORM_ID, + TransferState +} from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; + import { appConfig } from './app.config'; -import { provideAbpOAuth } from '@abp/ng.oauth'; -import { withRoutes, provideServerRendering } from '@angular/ssr'; import { appServerRoutes } from './app.routes.server'; +import { SSR_FLAG } from '@abp/ng.core'; const serverConfig: ApplicationConfig = { - providers: [provideAbpOAuth(), provideServerRendering(withRoutes(appServerRoutes))], + providers: [ + provideAppInitializer(() => { + const platformId = inject(PLATFORM_ID); + const transferState = inject(TransferState); + if (isPlatformServer(platformId)) { + transferState.set(SSR_FLAG, true); + } + }), + provideServerRendering(withRoutes(appServerRoutes)), + ], }; export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/npm/ng-packs/apps/dev-app/src/server.ts b/npm/ng-packs/apps/dev-app/src/server.ts index 4e5d443a97..076acc7e6c 100644 --- a/npm/ng-packs/apps/dev-app/src/server.ts +++ b/npm/ng-packs/apps/dev-app/src/server.ts @@ -8,6 +8,7 @@ import express from 'express'; import cookieParser from 'cookie-parser'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import {environment} from './environments/environment'; // ESM import import * as oidc from 'openid-client'; @@ -21,38 +22,27 @@ const browserDistFolder = resolve(serverDistFolder, '../browser'); const app = express(); const angularApp = new AngularNodeAppEngine(); -/** - * Example Express Rest API endpoints can be defined here. - * Uncomment and define endpoints as necessary. - * - * Example: - * ```ts - * app.get('/api/**', (req, res) => { - * // Handle API request - * }); - * ``` - */ - const ISSUER = new URL('https://localhost:44305/'); // OIDC issuer const CLIENT_ID = 'MyProjectName_App'; -const REDIRECT_URI = 'http://localhost:4200'; // IdP'ye aynen kaydet +const REDIRECT_URI = 'http://localhost:4200'; const SCOPE = 'offline_access MyProjectName'; const config = await oidc.discovery(ISSUER, CLIENT_ID, /* client_secret */ undefined); -const baseCookie = { httpOnly: false, sameSite: 'lax' as const, secure: false, path: '/' }; +const secureCookie = { httpOnly: false, sameSite: 'lax' as const, secure: environment.production, path: '/' }; +const tokenCookie = { ...secureCookie, httpOnly: false, maxAge: 60 * 60 * 24 * 1 }; // 30 days app.use(cookieParser()); const sessions = new Map(); -app.get('/login', async (_req, res) => { +app.get('/authorize', async (_req, res) => { const code_verifier = oidc.randomPKCECodeVerifier(); const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier); - const state = oidc.randomState(); + const state = oidc.randomState(); const sid = crypto.randomUUID(); sessions.set(sid, { pkce: code_verifier, state }); - res.cookie('sid', sid, baseCookie); + res.cookie('sid', sid, secureCookie); const url = oidc.buildAuthorizationUrl(config, { redirect_uri: REDIRECT_URI, @@ -96,8 +86,18 @@ app.get('/', async (req, res, next) => { const tokens = await resp.json(); console.log(tokens); + + const expiresInSec = + Number(tokens.expires_in ?? tokens.expiresIn ?? 3600); + const skewSec = 60; + const accessExpiresAt = new Date( + Date.now() + Math.max(0, expiresInSec - skewSec) * 1000 + ); + sessions.set(sid, { ...sess, at: tokens.access_token, refresh: tokens.refresh_token }); - res.cookie('access_token', tokens.access_token, baseCookie); + res.cookie('access_token', tokens.access_token, tokenCookie); + res.cookie('refresh_token', tokens.refresh_token, secureCookie); + res.cookie('expires_at', String(accessExpiresAt.getTime()), tokenCookie); return res.redirect('/'); } catch (e) { console.error('OIDC error:', e); diff --git a/npm/ng-packs/packages/core/src/lib/services/cookie-storage.service.ts b/npm/ng-packs/packages/core/src/lib/services/cookie-storage.service.ts new file mode 100644 index 0000000000..be383de1a9 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/cookie-storage.service.ts @@ -0,0 +1,85 @@ +import { Injectable, PLATFORM_ID, inject } from '@angular/core'; +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; + +@Injectable({ providedIn: 'root' }) +export class AbpCookieStorageService implements Storage { + private platformId = inject(PLATFORM_ID); + private document = inject(DOCUMENT); + private isBrowser = isPlatformBrowser(this.platformId); + + get length(): number { + return this.isBrowser ? this.keys().length : 0; + } + + clear(): void { + if (!this.isBrowser) return; + this.keys().forEach(k => this.removeItem(k)); + } + + getItem(key: string): string | null { + if (!this.isBrowser) return null; + const name = key + '='; + const parts = (this.document.cookie || '').split('; '); + for (const p of parts) { + if (p.startsWith(name)) { + return decodeURIComponent(p.slice(name.length)); + } + } + return null; + } + + key(index: number): string | null { + if (!this.isBrowser) return null; + return this.keys()[index] ?? null; + } + + removeItem(key: string): void { + if (!this.isBrowser) return; + this.setCookie(key, '', { 'max-age': -1, path: '/' }); + } + + setItem(key: string, value: string): void { + if (!this.isBrowser) return; + this.setCookie(key, encodeURIComponent(value), { + path: '/', + sameSite: 'Lax', + secure: true, + // İstersen kalıcı yapmak için maxAge / expires ekleyebilirsin + // 'max-age': 60 * 60, // 1 saat + }); + } + + setItemWithExpiry(key: string, value: string, seconds: number): void { + if (!this.isBrowser) return; + this.setCookie(key, encodeURIComponent(value), { + path: '/', + sameSite: 'Lax', + secure: true, + 'max-age': Math.max(0, Math.floor(seconds)), + }); + } + + private keys(): string[] { + const raw = (this.document.cookie || '').split('; ').filter(Boolean); + return raw + .map(c => decodeURIComponent(c.split('=')[0])); + } + + private setCookie(name: string, value: string, opts: { + path?: string; + domain?: string; + secure?: boolean; + sameSite?: 'Lax' | 'Strict' | 'None'; + expires?: Date; + 'max-age'?: number; + }) { + let s = `${name}=${value}`; + if (opts.path) s += `; Path=${opts.path}`; + if (opts.domain) s += `; Domain=${opts.domain}`; + if (opts.sameSite) s += `; SameSite=${opts.sameSite}`; + if (opts.secure) s += `; Secure`; + if (opts.expires) s += `; Expires=${opts.expires.toUTCString()}`; + if (typeof opts['max-age'] === 'number') s += `; Max-Age=${opts['max-age']}`; + this.document.cookie = s; + } +} diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts index fbf09e044d..20d4464fc6 100644 --- a/npm/ng-packs/packages/core/src/lib/services/index.ts +++ b/npm/ng-packs/packages/core/src/lib/services/index.ts @@ -26,3 +26,4 @@ export * from './title-strategy.service'; export * from './timezone.service'; export * from './time.service'; export * from './ssr.service'; +export * from './cookie-storage.service'; diff --git a/npm/ng-packs/packages/core/src/lib/tokens/index.ts b/npm/ng-packs/packages/core/src/lib/tokens/index.ts index 5ccb5160f0..6c7fd859c6 100644 --- a/npm/ng-packs/packages/core/src/lib/tokens/index.ts +++ b/npm/ng-packs/packages/core/src/lib/tokens/index.ts @@ -17,3 +17,4 @@ export * from './tenant-not-found-by-name'; export * from './compare-func.token'; export * from './dynamic-layout.token'; export * from './title-strategy-disable-project-name.token'; +export * from './ssr-state.token'; diff --git a/npm/ng-packs/packages/core/src/lib/tokens/ssr-state.token.ts b/npm/ng-packs/packages/core/src/lib/tokens/ssr-state.token.ts new file mode 100644 index 0000000000..a9a4448149 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tokens/ssr-state.token.ts @@ -0,0 +1,14 @@ +import { inject, InjectionToken, makeStateKey, PLATFORM_ID, TransferState } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; + +export const SSR_FLAG = makeStateKey('SSR_FLAG'); + +export const APP_STARTED_WITH_SSR = new InjectionToken('APP_STARTED_WITH_SSR', { + providedIn: 'root', + factory: () => { + const platformId = inject(PLATFORM_ID); + if (!isPlatformBrowser(platformId)) return true; + const ts = inject(TransferState); + return ts.get(SSR_FLAG, false); + }, +}); diff --git a/npm/ng-packs/packages/oauth/src/lib/services/remember-me.service.ts b/npm/ng-packs/packages/oauth/src/lib/services/remember-me.service.ts index 8ddc1194ff..25fd50198b 100644 --- a/npm/ng-packs/packages/oauth/src/lib/services/remember-me.service.ts +++ b/npm/ng-packs/packages/oauth/src/lib/services/remember-me.service.ts @@ -1,32 +1,49 @@ -import { AbpLocalStorageService } from '@abp/ng.core'; -import { Injectable, inject } from '@angular/core'; - -@Injectable({ - providedIn: 'root', -}) -export class RememberMeService { - readonly #rememberMe = 'remember_me'; - protected readonly localStorageService = inject(AbpLocalStorageService); - - set(remember: boolean) { - this.localStorageService.setItem(this.#rememberMe, JSON.stringify(remember)); - } - - remove() { - this.localStorageService.removeItem(this.#rememberMe); - } - - get() { - return Boolean(JSON.parse(this.localStorageService.getItem(this.#rememberMe) || 'false')); - } - - getFromToken(accessToken: string) { - const tokenBody = accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); - try { - const parsedToken = JSON.parse(atob(tokenBody)); - return Boolean(parsedToken[this.#rememberMe]); - } catch { - return false; - } - } -} +import { + AbpCookieStorageService, + AbpLocalStorageService, + APP_STARTED_WITH_SSR, +} from '@abp/ng.core'; +import { Injectable, inject } from '@angular/core'; + +@Injectable({ + providedIn: 'root', +}) +export class RememberMeService { + readonly #rememberMe = 'remember_me'; + protected readonly localStorageService = inject(AbpLocalStorageService); + protected readonly cookieStorageService = inject(AbpCookieStorageService); + private appStartedWithSsr = inject(APP_STARTED_WITH_SSR, { optional: true }); + + set(remember: boolean) { + if (this.appStartedWithSsr) { + this.cookieStorageService.setItem(this.#rememberMe, JSON.stringify(remember)); + return; + } + this.localStorageService.setItem(this.#rememberMe, JSON.stringify(remember)); + } + + remove() { + if (this.appStartedWithSsr) { + this.cookieStorageService.removeItem(this.#rememberMe); + return; + } + this.localStorageService.removeItem(this.#rememberMe); + } + + get() { + if (this.appStartedWithSsr) { + return Boolean(JSON.parse(this.cookieStorageService.getItem(this.#rememberMe) || 'false')); + } + return Boolean(JSON.parse(this.localStorageService.getItem(this.#rememberMe) || 'false')); + } + + getFromToken(accessToken: string) { + const tokenBody = accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + try { + const parsedToken = JSON.parse(atob(tokenBody)); + return Boolean(parsedToken[this.#rememberMe]); + } catch { + return false; + } + } +}