Skip to content
Open
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
13 changes: 10 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
VITE_OBP_API_HOST=http://127.0.0.1:8080
VITE_OBP_API_VERSION=v5.1.0

### OBP gRPC endpoint (used by the gRPC services browser) ###
### Defaults to grpc.<VITE_OBP_API_HOST hostname> if not set — port 443 with TLS for an https
### base URL, port 50051 without TLS for http (no grpc. prefix for localhost/IPs).
### VITE_OBP_GRPC_TLS=true|false overrides the port-based TLS default.
# VITE_OBP_GRPC_HOST=localhost:50051

### API Explorer Host ###
VITE_OBP_API_EXPLORER_HOST=http://localhost:5173

Expand Down Expand Up @@ -39,9 +45,6 @@ VITE_OBP_LOGOUT_MODE=public
VITE_OBP_OIDC_CLIENT_ID=your-obp-oidc-client-id
VITE_OBP_OIDC_CLIENT_SECRET=your-obp-oidc-client-secret

### OBP Consumer Key (for API calls) ###
VITE_OBP_CONSUMER_KEY=your-obp-oidc-client-id

### Keycloak Provider (Optional) ###
# VITE_KEYCLOAK_CLIENT_ID=your-keycloak-client-id
# VITE_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
Expand Down Expand Up @@ -84,3 +87,7 @@ VITE_CHATBOT_ENABLED=false

### Resource Docs Version ###
VITE_OBP_API_DEFAULT_RESOURCE_DOC_VERSION=OBPv7.0.0

### /status page: when set, the consumer_id shown under each OAuth2 provider links to the
### consumer's page on the API Manager, e.g. VITE_API_MANAGER_URL=http://localhost:3003
# VITE_API_MANAGER_URL=
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,20 @@ server {
}
```

## gRPC connection (gRPC services browser)

The gRPC services page connects to OBP-API over gRPC:

- `VITE_OBP_GRPC_HOST` — explicit gRPC target as `host:port`; always wins when set.
When unset, the target is derived from `VITE_OBP_API_HOST`:
- `https://api.example.com` → `grpc.api.example.com:443` (TLS ingress convention)
- `http://api.example.com` → `grpc.api.example.com:50051`
- `http://localhost:8080` or an IP → `localhost:50051` / `<ip>:50051` (no `grpc.` prefix)
- `VITE_OBP_GRPC_TLS` — `true` or `false`; forces TLS channel credentials on or off.
When unset, TLS is inferred from the port of the resolved host: `:443` → TLS on,
any other port → TLS off. Set this only for setups where the port is not a
reliable signal (e.g. TLS on a non-443 port).

Note: if you have issues with session stickyness / login issues, enable #DEBUG=express-session in your .env
and if you see messages like these in the log,

Expand Down
7 changes: 5 additions & 2 deletions server/routes/grpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ import { Router } from 'express'
import type { Request, Response } from 'express'
import { credentials } from '@grpc/grpc-js'
import { Client as ReflectionClient } from 'grpc-reflection-js'
import { resolveGrpcTarget } from '../utils/grpcHost.js'

const router = Router()

const GRPC_HOST = process.env.VITE_OBP_GRPC_HOST || 'localhost:50051'
const GRPC_TARGET = resolveGrpcTarget(process.env)
const GRPC_HOST = GRPC_TARGET.host
const GRPC_CREDENTIALS = GRPC_TARGET.tls ? credentials.createSsl() : credentials.createInsecure()

const REFLECTION_SERVICE_NAMES = new Set([
'grpc.reflection.v1.ServerReflection',
Expand Down Expand Up @@ -80,7 +83,7 @@ router.get('/grpc/services', async (_req: Request, res: Response) => {
let client: ReflectionClient | null = null
try {
console.log(`gRPC: Reflecting against ${GRPC_HOST}`)
client = new ReflectionClient(GRPC_HOST, credentials.createInsecure())
client = new ReflectionClient(GRPC_HOST, GRPC_CREDENTIALS)

const serviceNames = await client.listServices()
const services: ServiceInfo[] = []
Expand Down
24 changes: 22 additions & 2 deletions server/routes/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ import {
RESOURCE_DOCS_API_VERSION,
MESSAGE_DOCS_API_VERSION,
API_VERSIONS_LIST_API_VERSION,
V5_1_0
V5_1_0,
SSE_PROBE_SPACING_MS
} from '../../src/shared-constants.js'

const router = Router()
Expand Down Expand Up @@ -160,6 +161,26 @@ router.get('/health', (req: Request, res: Response) => {
})
})

/**
* GET /status/stream
* SSE transport probe for the status page: emits two spaced events so the
* browser can tell real streaming from a proxy-buffered response. Uses the
* same headers as the real Opey SSE stream and no proxy opt-outs
* (e.g. X-Accel-Buffering), so it experiences the same proxy behavior.
* Carries no data, so no auth.
*/
router.get('/status/stream', (req: Request, res: Response) => {
res.setHeader('Content-Type', 'text/event-stream')
res.setHeader('Cache-Control', 'no-cache')
res.setHeader('Connection', 'keep-alive')
res.write(':ok\n\ndata: {"seq":1}\n\n')
const timer = setTimeout(() => {
res.write('data: {"seq":2}\n\n')
res.end()
}, SSE_PROBE_SPACING_MS)
req.on('close', () => clearTimeout(timer))
})

/**
* GET /status
* Get application status and health checks
Expand Down Expand Up @@ -261,7 +282,6 @@ router.get('/status/providers', (req: Request, res: Response) => {
// Get env configuration (masked)
const envConfig = {
obpOidc: {
consumerId: process.env.VITE_OBP_CONSUMER_KEY || 'not configured',
clientId: maskCredential(process.env.VITE_OBP_OIDC_CLIENT_ID)
},
keycloak: {
Expand Down
65 changes: 64 additions & 1 deletion server/services/OIDCServiceHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import { Container } from 'typedi'
import { OAuth2ProviderManager } from './OAuth2ProviderManager.js'
import OBPClientService from './OBPClientService.js'

/**
* Deep per-provider OIDC health checks for the /status page.
Expand All @@ -48,6 +49,18 @@ interface TokenTestOutcome {
message: string
responseTimeMs: number
ranAt: number
/** The issued token, kept server-side so the consumer identity can be read with it. */
accessToken?: string
/** Which OBP Consumer the client is, read once per token from GET /obp/v7.0.0/consumers/current/identity. */
consumer?: ConsumerIdentity
}

/** The calling Consumer as OBP reports it: id and name only. */
interface ConsumerIdentity {
consumer_id?: string
consumer_name?: string
/** Set when the OBP-API has no identity endpoint yet, or refused the token. */
note?: string
}

const FETCH_TIMEOUT_MS = 5000
Expand Down Expand Up @@ -92,6 +105,39 @@ async function fetchJson(url: string): Promise<any> {
}
}

/**
* Which OBP Consumer a token belongs to. GET /obp/v7.0.0/consumers/current/identity needs no
* role and returns only consumer_id and consumer_name. Answers are attached to the cached
* token test, so this runs at most once per token.
*/
async function readConsumerIdentity(accessToken: string): Promise<ConsumerIdentity> {
const baseUri = Container.get(OBPClientService).getOBPClientConfig().baseUri.replace(/\/$/, '')
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort('timeout'), FETCH_TIMEOUT_MS)
try {
const response = await fetch(`${baseUri}/obp/v7.0.0/consumers/current/identity`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
signal: controller.signal
})
const body = (await response.json().catch(() => ({}))) as {
consumer_id?: string
consumer_name?: string
message?: string
}
if (response.ok && body.consumer_id) {
return { consumer_id: body.consumer_id, consumer_name: body.consumer_name ?? '' }
}
if (response.status === 404) {
return { note: 'not available: this OBP-API has no GET /obp/v7.0.0/consumers/current/identity' }
}
return { note: `OBP did not identify the application (${response.status}): ${body.message ?? response.statusText}` }
} catch (err) {
return { note: err instanceof Error ? err.message : String(err) }
} finally {
clearTimeout(timeoutId)
}
}

async function runTokenTest(
provider: string,
tokenEndpoint: string,
Expand Down Expand Up @@ -125,7 +171,14 @@ async function runTokenTest(
const responseTimeMs = Math.round(performance.now() - start)

if (response.ok) {
outcome = { ok: true, message: 'token issued', responseTimeMs, ranAt: Date.now() }
let accessToken: string | undefined
try {
accessToken = ((await response.json()) as { access_token?: string }).access_token
} catch {
// Token body not JSON: the test still passed, only the identity lookup is skipped
}
outcome = { ok: true, message: 'token issued', responseTimeMs, ranAt: Date.now(), accessToken }
outcome.consumer = accessToken ? await readConsumerIdentity(accessToken) : undefined
} else {
let message = `${response.status} ${response.statusText}`
try {
Expand Down Expand Up @@ -215,6 +268,16 @@ async function checkProvider(
const outcome = await runTokenTest(name, discovery.token_endpoint, clientId, clientSecret)
details.token_test = outcome.ok ? 'ok' : 'failed'
details.token_test_ms = outcome.responseTimeMs
if (outcome.consumer?.consumer_id) {
details.consumer_id = outcome.consumer.consumer_id
details.consumer_name = outcome.consumer.consumer_name ?? ''
const managerUrl = process.env.VITE_API_MANAGER_URL?.replace(/\/$/, '')
if (managerUrl) {
details.consumer_id_url = `${managerUrl}/consumers/${encodeURIComponent(outcome.consumer.consumer_id)}`
}
} else if (outcome.consumer?.note) {
details.consumer = outcome.consumer.note
}
if (!outcome.ok) {
// Non-strict: surfaced in details but does not flip the provider
// unhealthy — the client may be authorization_code-only.
Expand Down
66 changes: 66 additions & 0 deletions server/test/grpcHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest'
import { resolveGrpcTarget, defaultGrpcHost } from '../utils/grpcHost'

describe('resolveGrpcTarget', () => {
it('prefers VITE_OBP_GRPC_HOST when set', () => {
expect(
resolveGrpcTarget({
VITE_OBP_GRPC_HOST: 'grpc.example.com:9999',
VITE_OBP_API_HOST: 'https://api.example.com'
})
).toEqual({ host: 'grpc.example.com:9999', tls: false })
})

it('derives grpc.<host>:443 with TLS from an https VITE_OBP_API_HOST', () => {
expect(resolveGrpcTarget({ VITE_OBP_API_HOST: 'https://api.example.com' })).toEqual({
host: 'grpc.api.example.com:443',
tls: true
})
})

it('derives grpc.<host>:50051 without TLS from an http VITE_OBP_API_HOST', () => {
expect(resolveGrpcTarget({ VITE_OBP_API_HOST: 'http://obp.internal:8080' })).toEqual({
host: 'grpc.obp.internal:50051',
tls: false
})
})

it('turns on TLS for an explicit host on port 443', () => {
expect(resolveGrpcTarget({ VITE_OBP_GRPC_HOST: 'grpc.example.com:443' }).tls).toBe(true)
})

it('lets VITE_OBP_GRPC_TLS override the port-based default in both directions', () => {
expect(
resolveGrpcTarget({ VITE_OBP_GRPC_HOST: 'grpc.example.com:443', VITE_OBP_GRPC_TLS: 'false' })
.tls
).toBe(false)
expect(
resolveGrpcTarget({ VITE_OBP_GRPC_HOST: 'grpc.example.com:50051', VITE_OBP_GRPC_TLS: 'true' })
.tls
).toBe(true)
})

it('falls back to localhost:50051 without TLS when nothing is set', () => {
expect(resolveGrpcTarget({})).toEqual({ host: 'localhost:50051', tls: false })
})
})

describe('defaultGrpcHost', () => {
it('uses port 443 for https base URLs and 50051 for http ones', () => {
expect(defaultGrpcHost('https://api.example.com')).toBe('grpc.api.example.com:443')
expect(defaultGrpcHost('http://obp.internal:8080')).toBe('grpc.obp.internal:50051')
})

it('does not prefix grpc. onto localhost or IP literals', () => {
expect(defaultGrpcHost('http://localhost:8080')).toBe('localhost:50051')
expect(defaultGrpcHost('http://obp.localhost:8080')).toBe('obp.localhost:50051')
expect(defaultGrpcHost('http://127.0.0.1:8080')).toBe('127.0.0.1:50051')
expect(defaultGrpcHost('http://[::1]:8080')).toBe('[::1]:50051')
})

it('falls back to localhost when the base URL is unset or unparseable', () => {
expect(defaultGrpcHost(undefined)).toBe('localhost:50051')
expect(defaultGrpcHost('')).toBe('localhost:50051')
expect(defaultGrpcHost('not a url')).toBe('localhost:50051')
})
})
56 changes: 56 additions & 0 deletions server/utils/grpcHost.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// OBP-API deployments conventionally expose gRPC on a `grpc.` subdomain of the
// REST host. Behind a public (https) deployment that subdomain serves gRPC
// through the ingress on port 443 with TLS — a raw high port is typically not
// reachable there — so when VITE_OBP_GRPC_HOST is unset the default is
// grpc.<VITE_OBP_API_HOST hostname>:443 with TLS for https deployments, and
// port 50051 without TLS for http (dev) ones. localhost and IP literals get no
// `grpc.` prefix (there is no subdomain to resolve there).

export const DEFAULT_GRPC_PORT = 50051
export const DEFAULT_GRPC_TLS_PORT = 443

export interface GrpcTarget {
/** gRPC target as host:port (no scheme). */
host: string
/** Whether to dial with TLS channel credentials. */
tls: boolean
}

/**
* The gRPC target to connect to, resolved from an env-like record
* (pass `process.env`): VITE_OBP_GRPC_HOST when set, otherwise derived from
* VITE_OBP_API_HOST. TLS follows VITE_OBP_GRPC_TLS ("true"/"false") when set,
* otherwise the port: 443 means TLS.
*/
export function resolveGrpcTarget(env: Record<string, string | undefined>): GrpcTarget {
const host = env.VITE_OBP_GRPC_HOST || defaultGrpcHost(env.VITE_OBP_API_HOST)
const tls =
env.VITE_OBP_GRPC_TLS !== undefined
? env.VITE_OBP_GRPC_TLS === 'true'
: host.endsWith(`:${DEFAULT_GRPC_TLS_PORT}`)
return { host, tls }
}

export function defaultGrpcHost(obpApiHost: string | undefined | null): string {
if (obpApiHost) {
try {
const url = new URL(obpApiHost)
if (grpcSubdomainApplies(url.hostname)) {
const port = url.protocol === 'https:' ? DEFAULT_GRPC_TLS_PORT : DEFAULT_GRPC_PORT
return `grpc.${url.hostname}:${port}`
}
return `${url.hostname}:${DEFAULT_GRPC_PORT}`
} catch {
// unparseable base URL — fall through to localhost
}
}
return `localhost:${DEFAULT_GRPC_PORT}`
}

function grpcSubdomainApplies(hostname: string): boolean {
if (hostname === 'localhost' || hostname.endsWith('.localhost')) {
return false
}
// IPv4 literal; IPv6 literals contain ':' (URL.hostname keeps their brackets)
return !/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname) && !hostname.includes(':')
}
Loading