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,21 @@
# web-identity-infrastructure
Adaptadores de identidad del navegador.
- **Import**: `@task-stream/web/identity/infrastructure`
- **Capa**: `infrastructure` · **Tags**: `scope:web-identity`, `type:infrastructure`
## Contenido
- `HttpAuthGateway` (`/api/auth/*`), `GraphqlProfileGateway` (`me`), `LocalSessionStorage`.
- `BrowserAuthorizationFlow`: Authorization Code + PKCE con Web Crypto y `state`.
- `SessionTokenSource`: conecta el cliente GraphQL con la `AuthFacade`.
- `provideIdentityInfrastructure()`.
## Comandos
```sh
npx nx lint web-identity-infrastructure
```
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'ts',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ts',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,13 @@
{
"name": "web-identity-infrastructure",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/web/identity/infrastructure/src",
"prefix": "ts",
"projectType": "library",
"tags": ["scope:web-identity", "type:infrastructure"],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/identity-infrastructure.providers';
export { CALLBACK_PATH } from './lib/adapters/browser-authorization.flow';

View File

@@ -0,0 +1,90 @@
import { DOCUMENT } from '@angular/common';
import { inject, Injectable } from '@angular/core';
import {
AuthConfig,
AuthorizationFlow,
AuthorizationResponse,
} from '@task-stream/web/identity/domain';
const PENDING_KEY = 'task-stream.pkce';
export const CALLBACK_PATH = '/auth/callback';
interface PendingAuthorization {
state: string;
codeVerifier: string;
returnUrl: string;
}
/** Authorization Code + PKCE (RFC 7636) with the browser's Web Crypto. */
@Injectable()
export class BrowserAuthorizationFlow extends AuthorizationFlow {
private readonly document = inject(DOCUMENT);
async start(config: AuthConfig, returnUrl: string): Promise<void> {
const codeVerifier = randomString(64);
const state = randomString(32);
sessionStorage.setItem(
PENDING_KEY,
JSON.stringify({
state,
codeVerifier,
returnUrl,
} satisfies PendingAuthorization),
);
const url = new URL(config.authorizeUrl);
url.search = new URLSearchParams({
client_id: config.clientId,
redirect_uri: this.redirectUri(),
response_type: 'code',
scope: config.scopes.join(' '),
state,
code_challenge: await challengeOf(codeVerifier),
code_challenge_method: 'S256',
}).toString();
this.document.location.assign(url.toString());
}
complete(params: URLSearchParams): AuthorizationResponse {
const raw = sessionStorage.getItem(PENDING_KEY);
sessionStorage.removeItem(PENDING_KEY);
const pending = raw ? (JSON.parse(raw) as PendingAuthorization) : null;
const error = params.get('error_description') ?? params.get('error');
if (error) throw new Error(error);
const code = params.get('code');
if (!pending || !code || params.get('state') !== pending.state) {
throw new Error(
'The sign-in response is invalid or expired. Please try again.',
);
}
return {
code,
codeVerifier: pending.codeVerifier,
redirectUri: this.redirectUri(),
returnUrl: pending.returnUrl,
};
}
private redirectUri(): string {
return `${this.document.location.origin}${CALLBACK_PATH}`;
}
}
function randomString(length: number): string {
const alphabet =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
const bytes = crypto.getRandomValues(new Uint8Array(length));
return Array.from(bytes, (b) => alphabet[b % alphabet.length]).join('');
}
async function challengeOf(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier),
);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

View File

@@ -0,0 +1,37 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { API_CONFIG } from '@task-stream/web/shared/infrastructure';
import { CurrentUser, ProfileGateway } from '@task-stream/web/identity/domain';
import { print } from 'graphql';
import { map, Observable } from 'rxjs';
import { MeDocument, type MeQuery } from '../graphql/generated';
/**
* `me` with an explicit token: used right after signing in, before the
* session is stored, so it does not go through the shared Apollo auth link.
*/
@Injectable()
export class GraphqlProfileGateway extends ProfileGateway {
private readonly http = inject(HttpClient);
private readonly api = inject(API_CONFIG);
private readonly query = print(MeDocument);
me(accessToken: string): Observable<CurrentUser> {
return this.http
.post<{
data: MeQuery | null;
errors?: { message: string }[];
}>(
this.api.graphqlUrl,
{ query: this.query },
{ headers: { Authorization: `Bearer ${accessToken}` } },
)
.pipe(
map((response) => {
if (!response.data)
throw new Error(response.errors?.[0]?.message ?? 'Unauthenticated');
return response.data.me;
}),
);
}
}

View File

@@ -0,0 +1,65 @@
import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { API_CONFIG } from '@task-stream/web/shared/infrastructure';
import {
AuthConfig,
AuthGateway,
Session,
sessionFrom,
} from '@task-stream/web/identity/domain';
import { map, Observable } from 'rxjs';
interface TokenResponse {
access_token: string;
refresh_token: string | null;
expires_in: number;
}
/** The API's sign-in proxy to Pireon (/api/auth/*). */
@Injectable()
export class HttpAuthGateway extends AuthGateway {
private readonly http = inject(HttpClient);
private readonly api = inject(API_CONFIG);
config(): Observable<AuthConfig | null> {
return this.http.get<AuthConfig | null>(`${this.api.restUrl}/auth/config`);
}
exchangeCode(
code: string,
codeVerifier: string,
redirectUri: string,
): Observable<Session> {
return this.token({
grant_type: 'authorization_code',
code,
code_verifier: codeVerifier,
redirect_uri: redirectUri,
});
}
password(username: string, password: string): Observable<Session> {
return this.token({ grant_type: 'password', username, password });
}
refresh(refreshToken: string): Observable<Session> {
return this.token({
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
}
private token(body: Record<string, string>): Observable<Session> {
return this.http
.post<TokenResponse>(`${this.api.restUrl}/auth/token`, body)
.pipe(
map((token) =>
sessionFrom({
accessToken: token.access_token,
refreshToken: token.refresh_token,
expiresIn: token.expires_in,
}),
),
);
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { Session, SessionStorage } from '@task-stream/web/identity/domain';
const KEY = 'task-stream.session';
@Injectable()
export class LocalSessionStorage extends SessionStorage {
load(): Session | null {
try {
const raw = localStorage.getItem(KEY);
return raw ? (JSON.parse(raw) as Session) : null;
} catch {
return null;
}
}
save(session: Session): void {
try {
localStorage.setItem(KEY, JSON.stringify(session));
} catch {
// Private browsing / storage disabled: the session lasts this tab only.
}
}
clear(): void {
try {
localStorage.removeItem(KEY);
} catch {
// Nothing to clear.
}
}
}

View File

@@ -0,0 +1,17 @@
import { inject, Injectable } from '@angular/core';
import { AccessTokenSource } from '@task-stream/web/shared/infrastructure';
import { AuthFacade } from '@task-stream/web/identity/application';
/** Gives the shared GraphQL client the session's token (refreshing it when needed). */
@Injectable()
export class SessionTokenSource extends AccessTokenSource {
private readonly auth = inject(AuthFacade);
accessToken(): Promise<string | null> {
return this.auth.validAccessToken();
}
unauthenticated(): void {
this.auth.expire();
}
}

View File

@@ -0,0 +1,43 @@
/** Internal type. DO NOT USE DIRECTLY. */
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
/** Internal type. DO NOT USE DIRECTLY. */
export type Incremental<T> =
| T
| {
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
};
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type MeQueryVariables = Exact<{ [key: string]: never }>;
export type MeQuery = {
me: { id: string; name: string; email: string; appRoles: Array<string> };
};
export const MeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'me' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
{ kind: 'Field', name: { kind: 'Name', value: 'appRoles' } },
],
},
},
],
},
},
],
} as unknown as DocumentNode<MeQuery, MeQueryVariables>;

View File

@@ -0,0 +1,8 @@
query Me {
me {
id
name
email
appRoles
}
}

View File

@@ -0,0 +1,26 @@
import {
makeEnvironmentProviders,
type EnvironmentProviders,
} from '@angular/core';
import {
AuthGateway,
AuthorizationFlow,
ProfileGateway,
SessionStorage,
} from '@task-stream/web/identity/domain';
import { AccessTokenSource } from '@task-stream/web/shared/infrastructure';
import { SessionTokenSource } from './adapters/session.token-source';
import { BrowserAuthorizationFlow } from './adapters/browser-authorization.flow';
import { GraphqlProfileGateway } from './adapters/graphql-profile.gateway';
import { HttpAuthGateway } from './adapters/http-auth.gateway';
import { LocalSessionStorage } from './adapters/local-session.storage';
export function provideIdentityInfrastructure(): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: AuthGateway, useClass: HttpAuthGateway },
{ provide: ProfileGateway, useClass: GraphqlProfileGateway },
{ provide: SessionStorage, useClass: LocalSessionStorage },
{ provide: AuthorizationFlow, useClass: BrowserAuthorizationFlow },
{ provide: AccessTokenSource, useClass: SessionTokenSource },
]);
}

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
}

View File

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