first commit
This commit is contained in:
2
libs/web/identity/infrastructure/src/index.ts
Normal file
2
libs/web/identity/infrastructure/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './lib/identity-infrastructure.providers';
|
||||
export { CALLBACK_PATH } from './lib/adapters/browser-authorization.flow';
|
||||
@@ -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(/=+$/, '');
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,8 @@
|
||||
query Me {
|
||||
me {
|
||||
id
|
||||
name
|
||||
email
|
||||
appRoles
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user