first commit
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IdentityProvider,
|
||||
InvalidAccessTokenError,
|
||||
InvalidCredentialsError,
|
||||
} from '@task-stream/api/identity/application';
|
||||
import {
|
||||
AuthorizationConfig,
|
||||
TokenGrant,
|
||||
TokenSet,
|
||||
User,
|
||||
UserFactory,
|
||||
} from '@task-stream/api/identity/domain';
|
||||
import { PIREON_CONFIG, PireonConfig } from './pireon.config';
|
||||
|
||||
/** User resource as documented in https://oauth.pireon.dev/docs?api-docs.json */
|
||||
interface PireonUser {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
roles?: string[];
|
||||
app_roles?: string[];
|
||||
}
|
||||
|
||||
/** Driven adapter for the Pireon OAuth2 identity server (Laravel Passport). */
|
||||
@Injectable()
|
||||
export class PireonIdentityProvider extends IdentityProvider {
|
||||
private readonly logger = new Logger(PireonIdentityProvider.name);
|
||||
|
||||
constructor(
|
||||
@Inject(PIREON_CONFIG) private readonly config: PireonConfig,
|
||||
private readonly users: UserFactory,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async whoAmI(accessToken: string): Promise<User> {
|
||||
const response = await this.request('/api/user', accessToken);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new InvalidAccessTokenError();
|
||||
}
|
||||
this.assertOk(response, 'GET /api/user');
|
||||
return this.toUser((await response.json()) as PireonUser);
|
||||
}
|
||||
|
||||
async findUser(id: string, accessToken: string): Promise<User | null> {
|
||||
if (!/^\d+$/.test(id)) return null;
|
||||
const response = await this.request(`/api/users/${id}`, accessToken);
|
||||
if ([401, 403, 404].includes(response.status)) return null;
|
||||
this.assertOk(response, `GET /api/users/${id}`);
|
||||
return this.toUser((await response.json()) as PireonUser);
|
||||
}
|
||||
|
||||
async listUsers(accessToken: string): Promise<User[]> {
|
||||
const response = await this.request('/api/users', accessToken);
|
||||
if (response.status === 401 || response.status === 403) return [];
|
||||
this.assertOk(response, 'GET /api/users');
|
||||
const body = (await response.json()) as
|
||||
| PireonUser[]
|
||||
| { data: PireonUser[] };
|
||||
const users = Array.isArray(body) ? body : body.data;
|
||||
return users.map((u) => this.toUser(u));
|
||||
}
|
||||
|
||||
async issueToken(grant: TokenGrant): Promise<TokenSet> {
|
||||
const { clientId, clientSecret, scopes } = this.config;
|
||||
if (!clientId)
|
||||
throw new ServiceUnavailableException(
|
||||
'Sign-in is not configured (PIREON_CLIENT_ID)',
|
||||
);
|
||||
|
||||
const body = new URLSearchParams({ client_id: clientId });
|
||||
if (clientSecret) body.set('client_secret', clientSecret);
|
||||
switch (grant.type) {
|
||||
case 'authorization_code':
|
||||
body.set('grant_type', 'authorization_code');
|
||||
body.set('code', grant.code);
|
||||
body.set('code_verifier', grant.codeVerifier);
|
||||
body.set('redirect_uri', grant.redirectUri);
|
||||
break;
|
||||
case 'password':
|
||||
body.set('grant_type', 'password');
|
||||
body.set('username', grant.username);
|
||||
body.set('password', grant.password);
|
||||
body.set('scope', scopes.join(' '));
|
||||
break;
|
||||
case 'refresh_token':
|
||||
body.set('grant_type', 'refresh_token');
|
||||
body.set('refresh_token', grant.refreshToken);
|
||||
break;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL('/oauth/token', this.config.baseUrl), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Pireon unreachable (/oauth/token): ${(error as Error).message}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('Identity server unavailable');
|
||||
}
|
||||
if (response.status === 400 || response.status === 401) {
|
||||
const error = (await response.json().catch(() => ({}))) as {
|
||||
error_description?: string;
|
||||
message?: string;
|
||||
};
|
||||
throw new InvalidCredentialsError(
|
||||
error.error_description ?? error.message ?? 'Invalid credentials',
|
||||
);
|
||||
}
|
||||
this.assertOk(response, 'POST /oauth/token');
|
||||
const token = (await response.json()) as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
};
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token ?? null,
|
||||
expiresIn: token.expires_in,
|
||||
tokenType: token.token_type,
|
||||
};
|
||||
}
|
||||
|
||||
authorizationConfig(): AuthorizationConfig | null {
|
||||
if (!this.config.clientId) return null;
|
||||
return {
|
||||
authorizeUrl: new URL('/oauth/authorize', this.config.baseUrl).toString(),
|
||||
clientId: this.config.clientId,
|
||||
scopes: this.config.scopes,
|
||||
passwordGrant: this.config.passwordGrant,
|
||||
};
|
||||
}
|
||||
|
||||
private toUser(user: PireonUser): User {
|
||||
return this.users.create({
|
||||
id: String(user.id),
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
isActive: user.is_active,
|
||||
roles: user.roles,
|
||||
appRoles: user.app_roles,
|
||||
});
|
||||
}
|
||||
|
||||
private async request(path: string, accessToken: string): Promise<Response> {
|
||||
try {
|
||||
return await fetch(new URL(path, this.config.baseUrl), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(this.config.requestTimeoutMs),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Pireon unreachable (${path}): ${(error as Error).message}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('Identity server unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
private assertOk(response: Response, operation: string): void {
|
||||
if (response.ok) return;
|
||||
this.logger.error(`${operation} answered ${response.status}`);
|
||||
throw new ServiceUnavailableException('Identity server error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface PireonConfig {
|
||||
/** e.g. https://oauth.pireon.dev */
|
||||
baseUrl: string;
|
||||
/** Seconds a resolved token is trusted before asking Pireon again. */
|
||||
sessionTtlSeconds: number;
|
||||
requestTimeoutMs: number;
|
||||
/** OAuth client of task-stream in Pireon (Authorization Code + PKCE / password grant). */
|
||||
clientId?: string;
|
||||
/** Kept server-side: browsers sign in through /api/auth/token. */
|
||||
clientSecret?: string;
|
||||
scopes: string[];
|
||||
passwordGrant: boolean;
|
||||
}
|
||||
|
||||
export const PIREON_CONFIG = Symbol('PIREON_CONFIG');
|
||||
Reference in New Issue
Block a user