first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
# web-identity-domain
Sesión y usuario en el navegador.
- **Import**: `@task-stream/web/identity/domain`
- **Capa**: `domain` · **Tags**: `scope:web-identity`, `type:domain`
## Contenido
- `Session`, `CurrentUser`, `AuthConfig`, `isExpired`, `sessionFrom`.
- Puertos: `AuthGateway`, `ProfileGateway`, `SessionStorage`, `AuthorizationFlow`.
## Comandos
```sh
npx nx lint web-identity-domain
npx nx test web-identity-domain # Jest
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,3 @@
import baseConfig from '../../../../eslint.config.mjs';
export default [...baseConfig];

View File

@@ -0,0 +1,11 @@
module.exports = {
displayName: 'web-identity-domain',
preset: '../../../../jest.preset.js',
testEnvironment: 'node',
passWithNoTests: true,
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../../coverage/libs/web/identity/domain',
};

View File

@@ -0,0 +1,9 @@
{
"name": "web-identity-domain",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/identity/domain/src",
"projectType": "library",
"tags": ["scope:web-identity", "type:domain"],
"// targets": "to see all targets run: nx show project web-identity-domain --web",
"targets": {}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/session';
export * from './lib/ports';

View File

@@ -0,0 +1,41 @@
import { Observable } from 'rxjs';
import { AuthConfig, CurrentUser, Session } from './session';
/** The API's sign-in endpoints (/api/auth/*). */
export abstract class AuthGateway {
abstract config(): Observable<AuthConfig | null>;
abstract exchangeCode(
code: string,
codeVerifier: string,
redirectUri: string,
): Observable<Session>;
abstract password(username: string, password: string): Observable<Session>;
abstract refresh(refreshToken: string): Observable<Session>;
}
/** Who the current token belongs to (GraphQL `me`). */
export abstract class ProfileGateway {
abstract me(accessToken: string): Observable<CurrentUser>;
}
export abstract class SessionStorage {
abstract load(): Session | null;
abstract save(session: Session): void;
abstract clear(): void;
}
export interface AuthorizationResponse {
code: string;
codeVerifier: string;
redirectUri: string;
/** Where the user wanted to go before signing in. */
returnUrl: string;
}
/** Authorization Code + PKCE in the browser (redirect out and back). */
export abstract class AuthorizationFlow {
/** Leaves the app towards the identity server's authorize page. */
abstract start(config: AuthConfig, returnUrl: string): Promise<void>;
/** Validates `state` and returns what is needed to exchange the code. */
abstract complete(params: URLSearchParams): AuthorizationResponse;
}

View File

@@ -0,0 +1,23 @@
import { isExpired, sessionFrom } from './session';
describe('session', () => {
it('computes the expiry from expires_in', () => {
const session = sessionFrom(
{ accessToken: 't', refreshToken: 'r', expiresIn: 3600 },
1_000,
);
expect(session).toEqual({
accessToken: 't',
refreshToken: 'r',
expiresAt: 3_601_000,
});
});
it('treats tokens about to expire as expired', () => {
const session = sessionFrom({ accessToken: 't', expiresIn: 30 }, 0);
expect(isExpired(session, 0)).toBe(true);
expect(
isExpired(sessionFrom({ accessToken: 't', expiresIn: 3600 }, 0), 0),
).toBe(false);
});
});

View File

@@ -0,0 +1,46 @@
/** Tokens issued by Pireon (through the API's /api/auth/token proxy). */
export interface Session {
accessToken: string;
refreshToken: string | null;
/** Epoch milliseconds. */
expiresAt: number;
}
export interface CurrentUser {
id: string;
name: string;
email: string;
appRoles: string[];
}
/** How the browser can sign in (null config = the API has no Pireon client yet). */
export interface AuthConfig {
authorizeUrl: string;
clientId: string;
scopes: string[];
passwordGrant: boolean;
}
/** Refresh a little before the token actually expires. */
export const EXPIRY_MARGIN_MS = 60_000;
export function isExpired(session: Session, now = Date.now()): boolean {
return session.expiresAt - EXPIRY_MARGIN_MS <= now;
}
export function sessionFrom(
token: {
accessToken: string;
refreshToken?: string | null;
expiresIn?: number | null;
},
now = Date.now(),
): Session {
// Personal access tokens are long-lived and come without expiry: one year.
const expiresIn = token.expiresIn ?? 365 * 24 * 3600;
return {
accessToken: token.accessToken,
refreshToken: token.refreshToken ?? null,
expiresAt: now + expiresIn * 1000,
};
}

View File

@@ -0,0 +1,23 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "bundler",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"jest.config.cts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}