Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const client = new Client();
await client.authenticateOAuthPkce();
```

For custom transports, call `Client.createGRPCTransport(oidcConfig, { oidcTokenHolder })` with an `OAuthTokenHolder` (exported from this package). The usual path is `new Client(oidcConfig)`, which wires the holder and transport automatically.
For custom transports, call `Client.createGRPCTransport(oidcConfig, { oidcTokenHolder: client.oauthSession.oauthHolder })` after constructing a `Client` in OIDC mode, or pass a holder from your own OIDC flow. The usual path is `new Client(oidcConfig)`, which wires the session and transport automatically.

## Getting Started

Expand Down
8 changes: 4 additions & 4 deletions examples/example_interactive_oidc.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* DIRECTORY_CLIENT_TLS_SKIP_VERIFY.
*/

import { Client, Config, OAuthPkceError, TokenCache, models } from "agntcy-dir";
import { Client, Config, OAuthPkceError, models } from "agntcy-dir";

const DEFAULT_OIDC_ISSUER = "https://dev.idp.ads.outshift.io";
const DEFAULT_SERVER_ADDRESS = "dev.gateway.ads.outshift.io:443";
Expand Down Expand Up @@ -49,12 +49,12 @@ function parseArgs(argv) {
return out;
}

function hasUsableOidcTokenWithoutPkce() {
function hasUsableOidcTokenWithoutPkce(config) {
const authToken = (process.env.DIRECTORY_CLIENT_AUTH_TOKEN ?? "").trim();
if (authToken) {
return true;
}
return new TokenCache().getValidToken() !== undefined;
return new Client(config).hasCachedOAuthToken();
}

function parseOidcCallbackPort() {
Expand Down Expand Up @@ -103,7 +103,7 @@ async function buildClient() {
const config = buildConfig();
const client = new Client(config);

if (hasUsableOidcTokenWithoutPkce()) {
if (hasUsableOidcTokenWithoutPkce(config)) {
console.log("Using cached OIDC token.");
return client;
}
Expand Down
22 changes: 22 additions & 0 deletions src/client/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright AGNTCY Contributors (https://github.com/agntcy)
// SPDX-License-Identifier: Apache-2.0

export {
OAuthPkceError,
OAuthTokenHolder,
fetchOpenidConfiguration,
runLoopbackPkceLogin,
type OidcPkceConfig,
type OpenIdConfiguration,
} from './oauthPkce.js';
export {
CachedToken,
TokenCache,
TOKEN_CACHE_FILE,
DEFAULT_TOKEN_CACHE_DIR,
type CachedTokenJson,
} from './tokenCache.js';
export {
OAuthSessionManager,
cachedTokenFromResponse,
} from './session.js';
File renamed without changes.
109 changes: 109 additions & 0 deletions src/client/auth/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright AGNTCY Contributors (https://github.com/agntcy)
// SPDX-License-Identifier: Apache-2.0

import { Config } from '../config.js';
import {
fetchOpenidConfiguration,
OAuthTokenHolder,
runLoopbackPkceLogin,
} from './oauthPkce.js';
import { CachedToken, TokenCache } from './tokenCache.js';

export function cachedTokenFromResponse(
config: Config,
payload: Record<string, unknown>,
): CachedToken {
const expiresIn = payload.expires_in;
let expiresAt: Date | undefined;
if (typeof expiresIn === 'number' && Number.isFinite(expiresIn)) {
expiresAt = new Date(Date.now() + expiresIn * 1000);
} else if (typeof expiresIn === 'string' && expiresIn !== '') {
const n = Number(expiresIn);
if (Number.isFinite(n)) {
expiresAt = new Date(Date.now() + n * 1000);
}
}
const refreshToken = payload.refresh_token;
const tokenType = payload.token_type;
return new CachedToken(
String(payload.access_token),
typeof tokenType === 'string' ? tokenType : '',
'oidc',
config.oidcIssuer,
typeof refreshToken === 'string' ? refreshToken : '',
expiresAt,
'',
'',
'',
new Date(),
);
}

/** Coordinates OIDC token state with interactive PKCE flow and cache. */
export class OAuthSessionManager {
readonly config: Config;
private readonly tokenCache: TokenCache;
private _oauthHolder: OAuthTokenHolder | null = null;

constructor(config: Config, tokenCache?: TokenCache) {
this.config = config;
this.tokenCache = tokenCache ?? new TokenCache();

if (this.config.authMode === 'oidc') {
this._oauthHolder = new OAuthTokenHolder();
if (this.config.authToken) {
this._oauthHolder.setTokens(this.config.authToken);
} else {
const cachedToken = this.tokenCache.getValidToken();
if (cachedToken !== undefined) {
this._oauthHolder.setTokens(cachedToken.accessToken);
}
}
}
}

get oauthHolder(): OAuthTokenHolder | null {
return this._oauthHolder;
}

hasAccessToken(): boolean {
if (this._oauthHolder === null) {
return false;
}
try {
this._oauthHolder.getAccessToken();
return true;
} catch {
return false;
}
}

async authenticate(): Promise<void> {
if (this.config.authMode !== 'oidc') {
throw new Error("authenticateOAuthPkce() requires authMode='oidc'");
}
if (this.config.oidcIssuer === '') {
throw new Error('oidc_issuer is required for authenticateOAuthPkce()');
}
if (this.config.oidcClientId === '') {
throw new Error('oidc_client_id is required for authenticateOAuthPkce()');
}
if (this._oauthHolder === null) {
throw new Error('OAuth token holder not initialized');
}
const verify = !this.config.tlsSkipVerify;
const timeoutMs = Math.min(30_000, this.config.oidcAuthTimeout * 1000);
const meta = await fetchOpenidConfiguration(this.config.oidcIssuer, {
verify,
timeoutMs,
});
const payload = await runLoopbackPkceLogin(this.config, meta, {
verify,
timeoutMs: this.config.oidcAuthTimeout * 1000,
});
this._oauthHolder.updateFromTokenResponse(payload);
this.tokenCache.save(cachedTokenFromResponse(this.config, payload));
console.log('Authenticated with OAuth PKCE');
console.log('Access token acquired.');
}
}
File renamed without changes.
Loading
Loading