diff --git a/apps/data-management/src/bootstrap/__tests__/appInitialization.spec.ts b/apps/data-management/src/bootstrap/__tests__/appInitialization.spec.ts index 4d8be1432..8735ef09d 100644 --- a/apps/data-management/src/bootstrap/__tests__/appInitialization.spec.ts +++ b/apps/data-management/src/bootstrap/__tests__/appInitialization.spec.ts @@ -39,6 +39,12 @@ vi.mock('@hydroserver/client', () => { } }) +vi.mock('@/config/settings', () => ({ + settings: { + authenticationConfiguration: { csrfCookieName: 'hs_csrftoken' }, + }, +})) + vi.mock('@/composables/useVocabulary', () => ({ useVocabularyStore: () => ({ fetchAllVocabularies: fetchAllVocabulariesMock, @@ -92,6 +98,18 @@ describe('app initialization bootstrap', () => { expect(fetchAllVocabulariesMock).toHaveBeenCalledTimes(1) }) + it('passes the configured CSRF cookie name to the HydroServer client', async () => { + createHydroServerMock.mockResolvedValue(undefined) + fetchAllVocabulariesMock.mockResolvedValue(undefined) + + const { startAppInitialization } = await import('../appInitialization') + await startAppInitialization() + + expect(createHydroServerMock).toHaveBeenCalledWith( + expect.objectContaining({ csrfCookieName: 'hs_csrftoken' }) + ) + }) + it('does not mark workspace bootstrap complete when initialized while logged out', async () => { createHydroServerMock.mockResolvedValue(undefined) fetchAllVocabulariesMock.mockResolvedValue(undefined) diff --git a/apps/data-management/src/bootstrap/appInitialization.ts b/apps/data-management/src/bootstrap/appInitialization.ts index 8491695a5..f09d5f332 100644 --- a/apps/data-management/src/bootstrap/appInitialization.ts +++ b/apps/data-management/src/bootstrap/appInitialization.ts @@ -1,4 +1,5 @@ import { useVocabularyStore } from '@/composables/useVocabulary' +import { settings } from '@/config/settings' import { useUserStore } from '@/store/user' import { useWorkspaceStore } from '@/store/workspaces' import hs, { createHydroServer, User } from '@hydroserver/client' @@ -71,6 +72,7 @@ export function startAppInitialization() { hydroServerInitializationPromise = createHydroServer({ host: hydroServerHost, + csrfCookieName: settings.authenticationConfiguration.csrfCookieName, }) .then(() => { isHydroServerReady.value = true diff --git a/apps/data-management/src/config/settings.ts b/apps/data-management/src/config/settings.ts index e13e5eda0..715ea0bde 100644 --- a/apps/data-management/src/config/settings.ts +++ b/apps/data-management/src/config/settings.ts @@ -4,6 +4,7 @@ const devHost = import.meta.env.VITE_APP_PROXY_BASE_URL || 'http://127.0.0.1:800 const defaultSettings: AppSettings = { authenticationConfiguration: { hydroserverSignupEnabled: false, + csrfCookieName: 'csrftoken', providers: [], }, aboutInformation: { diff --git a/apps/data-management/src/models/settings.ts b/apps/data-management/src/models/settings.ts index 07511a712..3d4a62086 100644 --- a/apps/data-management/src/models/settings.ts +++ b/apps/data-management/src/models/settings.ts @@ -8,6 +8,7 @@ export interface Provider { interface AuthenticationConfiguration { hydroserverSignupEnabled: boolean + csrfCookieName: string providers: Provider[] } diff --git a/apps/docs/docs/developing-and-contributing/how-to/using-oidc-scopes.md b/apps/docs/docs/developing-and-contributing/how-to/using-oidc-scopes.md index 93d04b9f9..850ef57a7 100644 --- a/apps/docs/docs/developing-and-contributing/how-to/using-oidc-scopes.md +++ b/apps/docs/docs/developing-and-contributing/how-to/using-oidc-scopes.md @@ -26,6 +26,32 @@ Scopes don't imply each other. A token with `data:write` can't read, and a token it starts, so request each scope your app uses. Scopes only decide which endpoints a token can call. The user's workspace roles still decide which records it can see and change. +## Sending the token + +Send the access token in the `Authorization` header of each request: + +```http +GET /api/ogc/collections/workspaces/items +Authorization: Bearer +``` + +In Python, pass the token to `hydroserverpy` as `access_token`. To keep long-running code working after the token +expires, pass a function that returns a current token instead. It's called before each request. See the +[Python Client Reference](/user-guides/how-to/using-the-python-client#connection). + +```python +from hydroserverpy import HydroServer + +hs_api = HydroServer(host, access_token=get_access_token) +``` + +In TypeScript, pass `@hydroserver/client` an `accessTokenProvider` that returns a current token. See +[Tokens managed by your app](/developing-and-contributing/how-to/using-the-typescript-client#tokens-managed-by-your-app). + +```ts +await createHydroServer({ host, accessTokenProvider: getAccessToken }); +``` + ## Warnings on the consent screen `iam:write` is shown with a warning. A token with this scope can create service accounts and add collaborators, and diff --git a/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md b/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md index 0806b5804..4d7fb4370 100644 --- a/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md +++ b/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md @@ -150,6 +150,35 @@ if (accountRes.ok) { } ``` +### Tokens managed by your app + +If your app already holds HydroServer access tokens, for example because its backend signs users in through +HydroServer, pass an `accessTokenProvider` so the client uses those tokens instead of signing the user in itself: + +```ts +await createHydroServer({ + host: "https://playground.hydroserver.org", + accessTokenProvider: async () => { + const res = await fetch("/my-app/hydroserver-token/"); + return res.ok ? (await res.json()).access_token : null; + }, + onUnauthorized: () => window.location.assign("/login/"), +}); +``` + +- The provider is called before each request, so cache the token until shortly before it expires. Return `null` when + the user isn't signed in. +- When a request is rejected with a `401`, the client calls the provider again and retries once. If the retry is also + rejected, it calls `onUnauthorized` and emits `session:expired`. +- `hs.session.isAuthenticated` reflects whether the provider returned a token. `hs.user.get()` reads the user from the + OIDC UserInfo endpoint, so the token needs the `openid`, `profile`, and `email` scopes. +- Your app handles sign-in and sign-out: `hs.session.login()` and `hs.session.logout()` throw in this mode. +- `accessTokenProvider` can't be combined with the `oidc` option. + +The endpoint that returns the token should require your app's own sign-in, reject cross-origin requests, and send +`Cache-Control: no-store`. The browser can use the token for everything its scopes allow, so request only the +[scopes](/developing-and-contributing/how-to/using-oidc-scopes) your frontend needs. + ## Workspaces Workspaces are the top-level access-control boundary for HydroServer data. diff --git a/apps/docs/docs/user-guides/how-to/using-the-python-client.md b/apps/docs/docs/user-guides/how-to/using-the-python-client.md index 211712ec2..397100dd6 100644 --- a/apps/docs/docs/user-guides/how-to/using-the-python-client.md +++ b/apps/docs/docs/user-guides/how-to/using-the-python-client.md @@ -14,6 +14,12 @@ hs_api = HydroServer(host, email, password) # API key authentication hs_api = HydroServer(host, apikey=apikey) + +# OIDC access token authentication +hs_api = HydroServer(host, access_token=access_token) + +# OIDC access token from a function, called before each request +hs_api = HydroServer(host, access_token=get_access_token) ``` | Parameter | Type | Default | Description | @@ -22,6 +28,7 @@ hs_api = HydroServer(host, apikey=apikey) | `email` | `str` | `None` | Account email | | `password` | `str` | `None` | Account password | | `apikey` | `str` | `None` | API key (alternative to email/password) | +| `access_token` | `str \| Callable[[], str \| None]` | `None` | OIDC access token, or a function that returns a current one before each request so it can refresh expired tokens. | **Methods** diff --git a/apps/qc-app/src/main.ts b/apps/qc-app/src/main.ts index 6fc320e93..7973595fb 100644 --- a/apps/qc-app/src/main.ts +++ b/apps/qc-app/src/main.ts @@ -15,6 +15,18 @@ import { ensureCalibration } from '@uwrl/qc-utils' const app = createApp(App) +// Django embeds its settings in the page it serves. Vite's dev page doesn't +// have them, so the client falls back to its default cookie name. +function readCsrfCookieName(): string | undefined { + const raw = document.getElementById('app-settings')?.textContent + if (!raw) return undefined + try { + return JSON.parse(raw).authenticationConfiguration?.csrfCookieName + } catch { + return undefined + } +} + async function initializeApp() { app.use(store) // Restores the chosen time zone before anything is formatted. @@ -27,7 +39,10 @@ async function initializeApp() { // so the user/workspace rehydration below has a live client to talk // to. Mirrors data-management-app/src/main.ts. // - await createHydroServer({ host: hydroServerHost }) + await createHydroServer({ + host: hydroServerHost, + csrfCookieName: readCsrfCookieName(), + }) // Wire the singleton into the app's pre-existing pinia ref so every // `useHydroServer().hs` call site keeps working without churn. diff --git a/django/.env.example b/django/.env.example index ef43fe44b..41a835b1f 100644 --- a/django/.env.example +++ b/django/.env.example @@ -8,6 +8,7 @@ SAME_ORIGIN_FRONTEND = # True/False: Set when the frontend is served from CORS_ALLOW_CREDENTIALS = # True/False: Whether cross-origin requests may include credentials (cookies). Defaults to the opposite of STRICT_SECURITY — only needs to be set explicitly if you're running a split-origin frontend/backend deployment. LOG_FORMAT = # 'standard' or 'json'. Defaults to 'standard'. CSRF_TRUSTED_ORIGINS = # Comma-separated origins trusted for session-cookie-authenticated requests. Defaults to PROXY_BASE_URL; only needed if your frontend isn't same-origin with the API. +CSRF_COOKIE_NAME = # Name of the CSRF cookie. Defaults to 'csrftoken'; change it when another Django app on the same hostname uses that name. SECRET_KEY = # This is required by Django. Keep this value secret. WEB_CLIENT_URL = # The base URL of the frontend app; used to redirect users after login/logout. Defaults to PROXY_BASE_URL if SAME_ORIGIN_FRONTEND, otherwise http://127.0.0.1:1203. diff --git a/django/core/iam/auth/oidc_adapter.py b/django/core/iam/auth/oidc_adapter.py index c15a0e7d3..489161006 100644 --- a/django/core/iam/auth/oidc_adapter.py +++ b/django/core/iam/auth/oidc_adapter.py @@ -11,6 +11,11 @@ class HydroServerOIDCAdapter(DefaultOIDCAdapter): Kept out of the ID token to keep it lean and only returned to clients that request the standard "profile" scope. + + HydroServer has no usernames, so allauth omits the standard + "preferred_username" claim. The user's email stands in for it in both + the ID token and /userinfo, but only for clients that were also granted + the "email" scope. """ scope_display = { @@ -23,6 +28,9 @@ def get_claims(self, purpose, user, client, scopes, email=None, **kwargs): purpose, user, client, scopes, email=email, **kwargs ) + if "profile" in scopes and "email" in scopes and user.email: + claims.setdefault("preferred_username", user.email) + if purpose == "userinfo" and "profile" in scopes: claims.update(user.to_profile_claims()) diff --git a/django/hydroserver/settings.py b/django/hydroserver/settings.py index b7aa34b76..f209ba9eb 100644 --- a/django/hydroserver/settings.py +++ b/django/hydroserver/settings.py @@ -72,6 +72,7 @@ if origin.strip() ] +CSRF_COOKIE_NAME = env.str("CSRF_COOKIE_NAME", default="csrftoken") CSRF_COOKIE_SECURE = urlparse(PROXY_BASE_URL).scheme == "https" SESSION_COOKIE_SECURE = urlparse(PROXY_BASE_URL).scheme == "https" diff --git a/django/interfaces/web/views.py b/django/interfaces/web/views.py index d8bb3e604..8eb36ba84 100644 --- a/django/interfaces/web/views.py +++ b/django/interfaces/web/views.py @@ -14,6 +14,7 @@ def get_app_settings_context(): return { "authenticationConfiguration": { "hydroserverSignupEnabled": settings.ACCOUNT_SIGNUP_ENABLED, + "csrfCookieName": settings.CSRF_COOKIE_NAME, "providers": [ { "id": social_app.provider_id, diff --git a/django/tests/hydroserver/test_oidc_auth.py b/django/tests/hydroserver/test_oidc_auth.py index bd450cd9c..b74eb80b4 100644 --- a/django/tests/hydroserver/test_oidc_auth.py +++ b/django/tests/hydroserver/test_oidc_auth.py @@ -550,6 +550,34 @@ def test_id_token_never_gets_custom_claims(oidc_client): assert "organization" not in claims +@pytest.mark.parametrize("purpose", ["id_token", "userinfo"]) +def test_preferred_username_is_email_with_profile_and_email_scopes( + oidc_client, purpose +): + adapter = HydroServerOIDCAdapter() + user = UserFactory() + + claims = adapter.get_claims( + purpose, user, oidc_client, ["openid", "profile", "email"] + ) + + assert claims["preferred_username"] == user.email + + +@pytest.mark.parametrize( + "scopes", [["openid"], ["openid", "profile"], ["openid", "email"]] +) +def test_preferred_username_omitted_without_profile_and_email_scopes( + oidc_client, scopes +): + adapter = HydroServerOIDCAdapter() + user = UserFactory() + + for purpose in ("id_token", "userinfo"): + claims = adapter.get_claims(purpose, user, oidc_client, scopes) + assert "preferred_username" not in claims + + # --- /identity/o/api/userinfo end-to-end -------------------------------------------- @@ -564,6 +592,7 @@ def test_userinfo_endpoint_returns_custom_claims(client, oidc_client): assert response.status_code == 200 body = response.json() assert body["firstName"] == "Jane" + assert body["preferred_username"] == user.email assert body["isStaff"] is False assert body["isSuperuser"] is False assert body["ownedWorkspaceLimit"] == 1 diff --git a/packages/hydroserver-ts/README.md b/packages/hydroserver-ts/README.md index 6895da21f..7907068a2 100644 --- a/packages/hydroserver-ts/README.md +++ b/packages/hydroserver-ts/README.md @@ -29,6 +29,23 @@ if (response.ok) { Use `host: ""` for same-origin requests. Applications that prefer dependency injection can instead call `HydroServer.initialize({ host })` and pass the returned instance explicitly. +### Tokens managed by your app + +If your app already holds HydroServer access tokens, for example from a backend that signs users in through HydroServer, pass an `accessTokenProvider`. It's called before each request, so it should cache the token until shortly before it expires: + +```ts +await createHydroServer({ + host: "https://playground.hydroserver.org", + accessTokenProvider: async () => { + const res = await fetch("/my-app/hydroserver-token/"); + return res.ok ? (await res.json()).access_token : null; + }, + onUnauthorized: () => window.location.assign("/login/"), +}); +``` + +A request rejected with a `401` is retried once with a token fetched again from the provider; if it's still rejected, the client calls `onUnauthorized` and emits `session:expired`. Your app handles sign-in and sign-out in this mode, and the option can't be combined with `oidc`. See the [TypeScript client how-to guide](https://github.com/hydroserver2/hydroserver/blob/main/apps/docs/docs/developing-and-contributing/how-to/using-the-typescript-client.md#tokens-managed-by-your-app) for the scopes the token needs and how to serve it safely. + ### Account return destinations When taking a user to a server-rendered account page, provide the in-app path diff --git a/packages/hydroserver-ts/src/api/HydroServer.ts b/packages/hydroserver-ts/src/api/HydroServer.ts index 4051e72d7..51a561034 100644 --- a/packages/hydroserver-ts/src/api/HydroServer.ts +++ b/packages/hydroserver-ts/src/api/HydroServer.ts @@ -28,6 +28,7 @@ import { QualityControlOperationService, QualityControlSessionService, } from './services/quality-control.service' +import type { AccessTokenProvider } from './requestInterceptor' export type AuthTuple = [string, string] @@ -41,13 +42,19 @@ export interface HydroServerOIDCOptions { export interface HydroServerOptions { host: string oidc?: HydroServerOIDCOptions + accessTokenProvider?: AccessTokenProvider + onUnauthorized?: () => void + csrfCookieName?: string } +export type AuthMode = 'session' | 'oidc' | 'token' + export class HydroServer { readonly host: string readonly resolvedHost: string readonly baseRoute: string readonly oidc?: Required + readonly accessTokenProvider?: AccessTokenProvider private _workspaces?: WorkspaceService private _monitoringSites?: MonitoringSiteService @@ -82,7 +89,12 @@ export class HydroServer { private _qualityControlOperations?: QualityControlOperationService constructor(opts: HydroServerOptions) { - const { host, oidc } = opts + const { host, oidc, accessTokenProvider } = opts + if (oidc && accessTokenProvider) { + throw new Error( + 'HydroServer options `oidc` and `accessTokenProvider` cannot be used together.' + ) + } this.host = host.trim().replace(/\/+$/, '') this.resolvedHost = this.host || globalThis.location?.origin || '' this.baseRoute = `${this.host}/api/ogc` @@ -94,6 +106,13 @@ export class HydroServer { scope: oidc.scope ?? 'openid profile email', } : undefined + this.accessTokenProvider = accessTokenProvider + } + + get authMode(): AuthMode { + if (this.oidc) return 'oidc' + if (this.accessTokenProvider) return 'token' + return 'session' } static async initialize(options: HydroServerOptions): Promise { diff --git a/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts b/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts index d5c46f0dd..08f43feb3 100644 --- a/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/apiMethods.spec.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { apiMethods } from '../apiMethods' +import { + registerAccessTokenProvider, + registerUnauthorizedHandler, +} from '../requestInterceptor' const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { @@ -329,3 +333,74 @@ describe('paginatedFetch', () => { expect(response.data.map((item) => item.id)).toEqual(['1', '2', '3']) }) }) + +describe('access token retries', () => { + const endpoint = 'https://hydro.example.com/api/ogc/collections/workspaces/items/1' + const unauthorized = () => new Response(null, { status: 401 }) + const authorizationOf = (init: RequestInit) => + new Headers(init.headers).get('Authorization') + + afterEach(() => { + registerAccessTokenProvider(null) + registerUnauthorizedHandler(null) + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('retries a 401 once with a token fetched again from the provider', async () => { + const tokens = ['expired-token', 'fresh-token'] + registerAccessTokenProvider(async () => tokens.shift() ?? null) + const fetchMock = vi + .fn() + .mockResolvedValueOnce(unauthorized()) + .mockResolvedValueOnce(jsonResponse({ id: '1' })) + vi.stubGlobal('fetch', fetchMock) + + const response = await apiMethods.fetch(endpoint) + + expect(response.ok).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(authorizationOf(fetchMock.mock.calls[0][1])).toBe('Bearer expired-token') + expect(authorizationOf(fetchMock.mock.calls[1][1])).toBe('Bearer fresh-token') + }) + + it('notifies the unauthorized handler when the retry is also rejected', async () => { + const onUnauthorized = vi.fn() + registerAccessTokenProvider(async () => 'revoked-token') + registerUnauthorizedHandler(onUnauthorized) + const fetchMock = vi.fn().mockImplementation(async () => unauthorized()) + vi.stubGlobal('fetch', fetchMock) + + const response = await apiMethods.fetch(endpoint) + + expect(response.ok).toBe(false) + expect(response.status).toBe(401) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(onUnauthorized).toHaveBeenCalledTimes(1) + }) + + it('does not retry a 401 for a request sent without a token', async () => { + const onUnauthorized = vi.fn() + registerUnauthorizedHandler(onUnauthorized) + const fetchMock = vi.fn().mockImplementation(async () => unauthorized()) + vi.stubGlobal('fetch', fetchMock) + + await apiMethods.fetch(endpoint) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(onUnauthorized).not.toHaveBeenCalled() + }) + + it('does not resend a streamed body', async () => { + const onUnauthorized = vi.fn() + registerAccessTokenProvider(async () => 'token-123') + registerUnauthorizedHandler(onUnauthorized) + const fetchMock = vi.fn().mockImplementation(async () => unauthorized()) + vi.stubGlobal('fetch', fetchMock) + + await apiMethods.post(endpoint, new ReadableStream()) + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(onUnauthorized).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/hydroserver-ts/src/api/__tests__/getCSRFToken.spec.ts b/packages/hydroserver-ts/src/api/__tests__/getCSRFToken.spec.ts new file mode 100644 index 000000000..083e10bcf --- /dev/null +++ b/packages/hydroserver-ts/src/api/__tests__/getCSRFToken.spec.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getCSRFToken, registerCSRFCookieName } from '../getCSRFToken' + +afterEach(() => { + registerCSRFCookieName('csrftoken') + vi.unstubAllGlobals() +}) + +describe('getCSRFToken', () => { + it('reads the csrftoken cookie by default', () => { + vi.stubGlobal('document', { cookie: 'other=1; csrftoken=csrf-123' }) + + expect(getCSRFToken()).toBe('csrf-123') + }) + + it('reads the configured cookie name', () => { + vi.stubGlobal('document', { + cookie: 'csrftoken=other-app; hs_csrftoken=csrf-123', + }) + registerCSRFCookieName('hs_csrftoken') + + expect(getCSRFToken()).toBe('csrf-123') + }) + + it('ignores a csrftoken cookie when another name is configured', () => { + vi.stubGlobal('document', { cookie: 'csrftoken=other-app' }) + registerCSRFCookieName('hs_csrftoken') + + expect(getCSRFToken()).toBeNull() + }) +}) diff --git a/packages/hydroserver-ts/src/api/__tests__/requestInterceptor.spec.ts b/packages/hydroserver-ts/src/api/__tests__/requestInterceptor.spec.ts index 4bf87da2f..7c144d212 100644 --- a/packages/hydroserver-ts/src/api/__tests__/requestInterceptor.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/requestInterceptor.spec.ts @@ -2,10 +2,11 @@ import { registerAccessTokenProvider, requestInterceptor, } from '../requestInterceptor' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' afterEach(() => { registerAccessTokenProvider(null) + vi.unstubAllGlobals() }) describe('requestInterceptor', () => { @@ -31,6 +32,25 @@ describe('requestInterceptor', () => { ) }) + it('adds a CSRF header from the cookie when no token is available', async () => { + vi.stubGlobal('document', { cookie: 'csrftoken=csrf-123' }) + + const result = await requestInterceptor({}) + + expect(new Headers(result.headers).get('X-CSRFToken')).toBe('csrf-123') + expect(result.credentials).toBe('include') + }) + + it('omits the CSRF header and cookies when sending a token', async () => { + vi.stubGlobal('document', { cookie: 'csrftoken=csrf-123' }) + registerAccessTokenProvider(async () => 'token-123') + + const result = await requestInterceptor({}) + + expect(new Headers(result.headers).get('X-CSRFToken')).toBeNull() + expect(result.credentials).toBe('omit') + }) + it('Preserves unmodified options while updating header and body', async () => { const options = { headers: { diff --git a/packages/hydroserver-ts/src/api/__tests__/session.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/session.service.spec.ts index 7c27a4672..4d14aea50 100644 --- a/packages/hydroserver-ts/src/api/__tests__/session.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/session.service.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { HydroServer } from '../HydroServer' describe('SessionService account URLs', () => { @@ -65,3 +65,80 @@ describe('SessionService account URLs', () => { } }) }) + +describe('SessionService with an access token provider', () => { + it('cannot be combined with OIDC options', () => { + expect( + () => + new HydroServer({ + host: 'https://api.example.test', + oidc: { clientId: 'client' }, + accessTokenProvider: async () => 'token-123', + }) + ).toThrow('cannot be used together') + }) + + it('reports token mode', () => { + const client = new HydroServer({ + host: 'https://api.example.test', + accessTokenProvider: async () => 'token-123', + }) + + expect(client.authMode).toBe('token') + }) + + it('authenticates from the token the provider returns', async () => { + const provider = vi.fn().mockResolvedValue('token-123') + const client = await HydroServer.initialize({ + host: 'https://api.example.test', + accessTokenProvider: provider, + }) + + expect(provider).toHaveBeenCalledTimes(1) + expect(client.session.isAuthenticated).toBe(true) + expect(client.session.accessToken).toBe('token-123') + expect(client.user.isAuthenticated).toBe(true) + }) + + it('is signed out when the provider has no token', async () => { + const client = await HydroServer.initialize({ + host: 'https://api.example.test', + accessTokenProvider: async () => null, + }) + + expect(client.session.isAuthenticated).toBe(false) + expect(client.session.accessToken).toBeNull() + }) + + it('is signed out when the provider fails', async () => { + const client = await HydroServer.initialize({ + host: 'https://api.example.test', + accessTokenProvider: async () => { + throw new Error('token endpoint unavailable') + }, + }) + + expect(client.session.isAuthenticated).toBe(false) + }) + + it('tracks the latest token returned by getAccessToken()', async () => { + const tokens = ['token-1', 'token-2'] + const client = await HydroServer.initialize({ + host: 'https://api.example.test', + accessTokenProvider: async () => tokens.shift() ?? null, + }) + + expect(await client.session.getAccessToken()).toBe('token-2') + expect(client.session.accessToken).toBe('token-2') + }) + + it('leaves sign-in and sign-out to the host app', async () => { + const client = new HydroServer({ + host: 'https://api.example.test', + accessTokenProvider: async () => 'token-123', + }) + + await expect(client.session.login('/')).rejects.toThrow('managed by the host app') + await expect(client.session.logout('/')).rejects.toThrow('managed by the host app') + }) +}) diff --git a/packages/hydroserver-ts/src/api/__tests__/user.service.spec.ts b/packages/hydroserver-ts/src/api/__tests__/user.service.spec.ts index 0f3cbe556..2f14f7362 100644 --- a/packages/hydroserver-ts/src/api/__tests__/user.service.spec.ts +++ b/packages/hydroserver-ts/src/api/__tests__/user.service.spec.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { HydroServer } from '../HydroServer' +import { registerAccessTokenProvider } from '../requestInterceptor' import { User, Workspace } from '../../types' function makeClientWithCachedUser(user: User): HydroServer { @@ -109,3 +110,33 @@ describe('UserService.can', () => { ).resolves.toBe(false) }) }) + +describe('UserService with an access token provider', () => { + afterEach(() => { + registerAccessTokenProvider(null) + vi.unstubAllGlobals() + }) + + it('fetches the user from the OIDC UserInfo endpoint', async () => { + const client = await HydroServer.initialize({ + host: 'https://hydro.example.com', + accessTokenProvider: async () => 'token-123', + }) + registerAccessTokenProvider(() => client.session.getAccessToken()) + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ email: 'user@example.com' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', fetchMock) + + const response = await client.user.get() + + expect(response.ok).toBe(true) + expect(fetchMock).toHaveBeenCalledTimes(1) + const [url, init] = fetchMock.mock.calls[0] + expect(url).toBe('https://hydro.example.com/identity/o/api/userinfo') + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer token-123') + }) +}) diff --git a/packages/hydroserver-ts/src/api/apiMethods.ts b/packages/hydroserver-ts/src/api/apiMethods.ts index e3eee5998..6f6f26d59 100644 --- a/packages/hydroserver-ts/src/api/apiMethods.ts +++ b/packages/hydroserver-ts/src/api/apiMethods.ts @@ -1,4 +1,8 @@ -import { requestInterceptor, RequestOptions } from './requestInterceptor' +import { + notifyUnauthorized, + requestInterceptor, + RequestOptions, +} from './requestInterceptor' import { ApiResponse, responseInterceptor } from './responseInterceptor' import { createPatchObject } from './createPatchObject' import pLimit from 'p-limit' @@ -6,12 +10,33 @@ import pLimit from 'p-limit' const limit = pLimit(10) const DEFAULT_PAGE_SIZE = 200 +function canResend(body: BodyInit | null | undefined): boolean { + return !(typeof ReadableStream !== 'undefined' && body instanceof ReadableStream) +} + +/** Sends a request, retrying a token-authenticated 401 once with a fresh token. */ +async function send( + endpoint: string, + options: RequestOptions +): Promise { + const opts = await requestInterceptor(options) + let response = await fetch(endpoint, opts) + // requestInterceptor omits credentials exactly when it attaches a token. + const usedToken = opts.credentials === 'omit' + if (response.status !== 401 || !usedToken) return response + + if (canResend(opts.body)) { + response = await fetch(endpoint, await requestInterceptor(options)) + } + if (response.status === 401) notifyUnauthorized() + return response +} + async function interceptedFetch( endpoint: string, options: RequestOptions ): Promise> { - const opts = await requestInterceptor(options) - const response = await fetch(endpoint, opts) + const response = await send(endpoint, options) return await responseInterceptor(response) } @@ -88,8 +113,7 @@ export const apiMethods = { /** GET `endpoint` and return the value of response header `name`. */ async fetchHeader(endpoint: string, name: string): Promise> { - const opts = await requestInterceptor({ method: 'GET' }) - const response = await limit(() => fetch(endpoint, opts)) + const response = await limit(() => send(endpoint, { method: 'GET' })) const res = await responseInterceptor(response) if (!res.ok) return res const value = response.headers.get(name) diff --git a/packages/hydroserver-ts/src/api/getCSRFToken.ts b/packages/hydroserver-ts/src/api/getCSRFToken.ts index 94c9a918a..74f44dbed 100644 --- a/packages/hydroserver-ts/src/api/getCSRFToken.ts +++ b/packages/hydroserver-ts/src/api/getCSRFToken.ts @@ -1,7 +1,13 @@ +let csrfCookieName = 'csrftoken' + +export function registerCSRFCookieName(name: string) { + csrfCookieName = name +} + export function getCSRFToken(): string | null { if (typeof document === 'undefined') return null - const name = 'csrftoken=' + const name = `${csrfCookieName}=` const decodedCookies = decodeURIComponent(document.cookie || '') const parts = decodedCookies.split(';') for (const part of parts) { diff --git a/packages/hydroserver-ts/src/api/requestInterceptor.ts b/packages/hydroserver-ts/src/api/requestInterceptor.ts index ae8795a01..c3dbd6b3d 100644 --- a/packages/hydroserver-ts/src/api/requestInterceptor.ts +++ b/packages/hydroserver-ts/src/api/requestInterceptor.ts @@ -1,8 +1,9 @@ import { getCSRFToken } from './getCSRFToken' -type AccessTokenProvider = () => Promise +export type AccessTokenProvider = () => Promise let accessTokenProvider: AccessTokenProvider | null = null +let unauthorizedHandler: (() => void) | null = null export function registerAccessTokenProvider( provider: AccessTokenProvider | null @@ -10,6 +11,14 @@ export function registerAccessTokenProvider( accessTokenProvider = provider } +export function registerUnauthorizedHandler(handler: (() => void) | null) { + unauthorizedHandler = handler +} + +export function notifyUnauthorized() { + unauthorizedHandler?.() +} + export interface RequestOptions extends Omit { body?: unknown } @@ -34,8 +43,8 @@ function isBodyInit(value: unknown): value is BodyInit { /** * Intercepts and enhances a request options object. * - * - Adds a CSRF header when a CSRF cookie is present (session auth) - * - Adds a bearer token when an access token provider is registered (OIDC auth) + * - Adds a bearer token when an access token provider returns one (OIDC or token auth) + * - Otherwise adds a CSRF header when a CSRF cookie is present (session auth) * - If a body is present and it's an object, the body is stringified. */ export async function requestInterceptor( @@ -50,17 +59,17 @@ export async function requestInterceptor( : JSON.stringify(options.body) } - const csrfToken = getCSRFToken() - if (csrfToken) { - headers.set('X-CSRFToken', csrfToken) - } - const accessToken = accessTokenProvider ? await accessTokenProvider().catch(() => null) : null if (accessToken) { headers.set('Authorization', `Bearer ${accessToken}`) + } else { + const csrfToken = getCSRFToken() + if (csrfToken) { + headers.set('X-CSRFToken', csrfToken) + } } return { diff --git a/packages/hydroserver-ts/src/api/runtime.ts b/packages/hydroserver-ts/src/api/runtime.ts index bbd7f1144..3d6d36057 100644 --- a/packages/hydroserver-ts/src/api/runtime.ts +++ b/packages/hydroserver-ts/src/api/runtime.ts @@ -1,5 +1,9 @@ import { HydroServer, HydroServerOptions } from './HydroServer' -import { registerAccessTokenProvider } from './requestInterceptor' +import { registerCSRFCookieName } from './getCSRFToken' +import { + registerAccessTokenProvider, + registerUnauthorizedHandler, +} from './requestInterceptor' let _hs: HydroServer | null = null let _creating: Promise | null = null @@ -61,7 +65,16 @@ export async function createHydroServer( // Rebind the proxy target to the actual instance Object.setPrototypeOf(hs, _hs) + registerCSRFCookieName(opts.csrfCookieName ?? 'csrftoken') registerAccessTokenProvider(() => client.session.getAccessToken()) + registerUnauthorizedHandler( + client.authMode === 'token' + ? () => { + opts.onUnauthorized?.() + client.emit('session:expired') + } + : null + ) attachGlobalSessionGuards() _creating = null return client diff --git a/packages/hydroserver-ts/src/api/services/session.service.ts b/packages/hydroserver-ts/src/api/services/session.service.ts index 6f85d242b..77d6920b5 100644 --- a/packages/hydroserver-ts/src/api/services/session.service.ts +++ b/packages/hydroserver-ts/src/api/services/session.service.ts @@ -31,6 +31,7 @@ export class SessionService { private readonly _client: HydroServer private _manager: UserManager | null = null private _user: OidcUser | null = null + private _externalToken: string | null = null private _eventsBound = false constructor(client: HydroServer) { @@ -66,17 +67,25 @@ export class SessionService { } get isAuthenticated(): boolean { - if (!this._client.oidc) { + if (this._client.authMode === 'session') { return this._client.user.isAuthenticated } + if (this._client.authMode === 'token') { + return this._externalToken !== null + } return Boolean(this._user?.access_token && !this._user.expired) } get accessToken(): string | null { + if (this._client.authMode === 'token') return this._externalToken return this.isAuthenticated ? this._user!.access_token : null } async initialize(): Promise { + if (this._client.authMode === 'token') { + await this.getAccessToken() + return + } if (!this._client.oidc || !isBrowser()) return const manager = this.getManager() @@ -93,6 +102,7 @@ export class SessionService { } async login(returnTo = this.getCurrentPath()): Promise { + this.assertNotTokenMode() if (!this._client.oidc) { if (!isBrowser()) return window.location.assign(this.getAccountUrl('/accounts/login/', returnTo)) @@ -117,6 +127,7 @@ export class SessionService { } async logout(returnTo = '/'): Promise { + this.assertNotTokenMode() if (!this._client.oidc) { if (!isBrowser()) return removeHydroServerStorage() @@ -147,6 +158,12 @@ export class SessionService { } async getAccessToken(): Promise { + if (this._client.authMode === 'token') { + this._externalToken = + (await this._client.accessTokenProvider!().catch(() => null)) ?? null + return this._externalToken + } + if (!this._client.oidc || !isBrowser()) return null const manager = this.getManager() @@ -171,6 +188,14 @@ export class SessionService { void this.getAccessToken() } + private assertNotTokenMode(): void { + if (this._client.authMode === 'token') { + throw new Error( + 'Sign-in and sign-out are managed by the host app when `accessTokenProvider` is set.' + ) + } + } + private getManager(): UserManager { if (!this._client.oidc) { throw new Error( diff --git a/packages/hydroserver-ts/src/api/services/user.service.ts b/packages/hydroserver-ts/src/api/services/user.service.ts index 4dbb5f9b0..b6b1763a1 100644 --- a/packages/hydroserver-ts/src/api/services/user.service.ts +++ b/packages/hydroserver-ts/src/api/services/user.service.ts @@ -77,13 +77,13 @@ export class UserService { /** * Synchronous authentication check. Session mode reads the `current-user` - * payload embedded in the SPA shell; OIDC mode defers to SessionService's - * own token-based check, since resolveCachedUser()'s script-tag lookup - * doesn't apply there (and would otherwise permanently cache a false - * "not authenticated" the first time this getter is called in OIDC mode). + * payload embedded in the SPA shell; OIDC and token modes defer to + * SessionService's own token-based check, since resolveCachedUser()'s + * script-tag lookup doesn't apply there (and would otherwise permanently + * cache a false "not authenticated" the first time this getter is called). */ get isAuthenticated(): boolean { - if (this._client.oidc) { + if (this._client.authMode !== 'session') { return this._client.session.isAuthenticated } return this.resolveCachedUser() !== null @@ -110,7 +110,8 @@ export class UserService { * always go through a full navigation (login/logout redirects), so it * stays accurate for the SPA's lifetime. * - * OIDC mode: fetched once from the OIDC UserInfo endpoint and cached. + * OIDC and token modes: fetched once from the OIDC UserInfo endpoint and + * cached, so the token needs the `openid profile email` scopes. * HydroServer's OIDC provider adds HydroServer-specific claims * (organization, isSuperuser, etc.) to that response for clients * requesting the `profile` scope (see core/iam/auth/oidc_adapter.py). @@ -118,7 +119,7 @@ export class UserService { * the accessTokenProvider registered in runtime.ts. */ get = async (): Promise> => { - if (this._client.oidc) { + if (this._client.authMode !== 'session') { if (this._cachedUser) { return { ok: true, data: this._cachedUser, status: 200, message: 'OK' } } diff --git a/packages/hydroserver-ts/src/index.ts b/packages/hydroserver-ts/src/index.ts index e333d9278..99f10b07c 100644 --- a/packages/hydroserver-ts/src/index.ts +++ b/packages/hydroserver-ts/src/index.ts @@ -6,4 +6,5 @@ export type * from './api/services' export * from './generated/contracts' export * from './types' export * from './api/responseInterceptor' +export type { AccessTokenProvider } from './api/requestInterceptor' export * from './api/observationProfiles' diff --git a/packages/hydroserverpy/README.md b/packages/hydroserverpy/README.md index beb15e25a..f285c483d 100644 --- a/packages/hydroserverpy/README.md +++ b/packages/hydroserverpy/README.md @@ -48,7 +48,39 @@ hs_api = HydroServer( apikey='hs_your_api_key' ) ``` - + +### Example: OIDC Access Token Authentication + +Applications that sign users in through HydroServer's OpenID Connect provider can act on the user's behalf with the access token they received: + +```python +from hydroserverpy import HydroServer + +hs_api = HydroServer( + host='https://playground.hydroserver.org', + access_token='your_access_token' +) +``` + +Access tokens expire, so you can pass a function instead of a string. It is called before every request, so it can return a refreshed token when the current one has expired. For example, with a token stored by python-social-auth: + +```python +from social_django.utils import load_strategy + +def get_access_token(): + social = user.social_auth.get(provider='oidc') + if social.access_token_expired(): + social.refresh_token(load_strategy()) + return social.extra_data['access_token'] + +hs_api = HydroServer( + host='https://playground.hydroserver.org', + access_token=get_access_token +) +``` + +The token must carry the scopes for the operations you perform, for example `data:read` to read monitoring sites, datastreams, and observations, `data:write` to change them, and `workspace:read` to list workspaces. Requests made without a required scope fail with a 403 error that names the missing scope. + ## Funding and Acknowledgements Funding for this project was provided by the National Oceanic & Atmospheric Administration (NOAA), awarded to the Cooperative Institute for Research to Operations in Hydrology (CIROH) through the NOAA Cooperative Agreement with The University of Alabama (NA22NWS4320003). Utah State University is a founding member of CIROH and receives funding under subaward from the University of Alabama. Additional funding and support have been provided by the State of Utah Division of Water Rights, the World Meteorological Organization, and the Utah Water Research laboratory at Utah State University. diff --git a/packages/hydroserverpy/src/hydroserverpy/api/README.md b/packages/hydroserverpy/src/hydroserverpy/api/README.md index d641dea04..e308a5bce 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/README.md +++ b/packages/hydroserverpy/src/hydroserverpy/api/README.md @@ -26,6 +26,15 @@ hs_api = HydroServer( ) ``` +Or with an OIDC access token issued by HydroServer, either as a string or as a function that returns a current token before each request: + +```python +hs_api = HydroServer( + host='https://playground.hydroserver.org', + access_token=get_access_token +) +``` + The hydroserverpy connection instance exposes the following types of core data and metadata you can retrieve or create, either as a collection or by ID using the `list`, `get`, or `create` methods of the associated property: * workspaces diff --git a/packages/hydroserverpy/src/hydroserverpy/api/client.py b/packages/hydroserverpy/src/hydroserverpy/api/client.py index 16ceff2c6..986b7f25b 100644 --- a/packages/hydroserverpy/src/hydroserverpy/api/client.py +++ b/packages/hydroserverpy/src/hydroserverpy/api/client.py @@ -1,6 +1,6 @@ import requests import json -from typing import Optional, Tuple +from typing import Callable, Optional, Tuple, Union from hydroserverpy.api.services import ( WorkspaceService, RoleService, @@ -30,17 +30,24 @@ def __init__( email: Optional[str] = None, password: Optional[str] = None, apikey: Optional[str] = None, + access_token: Optional[Union[str, Callable[[], Optional[str]]]] = None, ): self.host = host.strip("/") self.base_route = base_route if (email is None) ^ (password is None): raise ValueError("Both email and password must be provided together.") + if access_token is not None and (apikey is not None or email is not None): + raise ValueError( + "Provide only one of access_token, apikey, or email and password." + ) self.auth: Optional[Tuple[str, str]] = None self.apikey = apikey if email and password: self.auth = (email, password) + self._access_token = access_token + self._session = None self._timeout = 60 @@ -68,11 +75,21 @@ def request(self, method, path, *args, **kwargs) -> requests.Response: key: self._encode_query_value(value) for key, value in kwargs["params"].items() } + caller_headers = kwargs.pop("headers", None) or {} + for attempt in range(2): + headers = dict(caller_headers) + access_token = self._resolve_access_token() + if access_token and not any( + key.lower() == "authorization" for key in headers + ): + headers["Authorization"] = f"Bearer {access_token}" + try: response = getattr(self._session, method)( f"{self.host}/{path.strip('/')}", timeout=self._timeout, + headers=headers, *args, **kwargs, ) @@ -93,6 +110,11 @@ def request(self, method, path, *args, **kwargs) -> requests.Response: return response + def _resolve_access_token(self) -> Optional[str]: + if callable(self._access_token): + return self._access_token() + return self._access_token + def _init_session(self, auth: Optional[Tuple[str, str]] = None) -> None: if self._session is not None: self._session.close() diff --git a/packages/hydroserverpy/tests/test_api_client.py b/packages/hydroserverpy/tests/test_api_client.py index 500fcfade..cd011fad6 100644 --- a/packages/hydroserverpy/tests/test_api_client.py +++ b/packages/hydroserverpy/tests/test_api_client.py @@ -96,6 +96,104 @@ def test_hydroserver_requires_both_email_and_password(fake_session_factory): ) +def test_hydroserver_sends_access_token_as_bearer_header(fake_session_factory): + hs = client_module.HydroServer(host="https://example.com", access_token="token-1") + fake_session_factory[0].queue("get", FakeResponse()) + + hs.request("get", "/api/ogc/collections/workspaces/items") + + assert hs._session.auth is None + assert "X-API-Key" not in hs._session.headers + assert fake_session_factory[0].last_kwargs["headers"] == { + "Authorization": "Bearer token-1" + } + + +def test_hydroserver_calls_access_token_provider_before_each_request( + fake_session_factory, +): + tokens = iter(["token-1", "token-2"]) + hs = client_module.HydroServer( + host="https://example.com", access_token=lambda: next(tokens) + ) + fake_session_factory[0].queue("get", FakeResponse(), FakeResponse()) + + hs.request("get", "/api/ogc/collections/workspaces/items") + first_headers = fake_session_factory[0].last_kwargs["headers"] + hs.request("get", "/api/ogc/collections/workspaces/items") + second_headers = fake_session_factory[0].last_kwargs["headers"] + + assert first_headers == {"Authorization": "Bearer token-1"} + assert second_headers == {"Authorization": "Bearer token-2"} + + +def test_hydroserver_retries_401_with_refreshed_access_token( + fake_session_factory, monkeypatch +): + tokens = iter(["expired-token", "fresh-token"]) + hs = client_module.HydroServer( + host="https://example.com", access_token=lambda: next(tokens) + ) + fake_session_factory[0].queue("get", FakeResponse(status_code=401)) + + replacement_session = FakeSession() + replacement_session.queue("get", FakeResponse()) + monkeypatch.setattr(client_module.requests, "Session", lambda: replacement_session) + + hs.request("get", "/api/ogc/collections/workspaces/items") + + assert fake_session_factory[0].last_kwargs["headers"] == { + "Authorization": "Bearer expired-token" + } + assert hs._session is replacement_session + assert replacement_session.last_kwargs["headers"] == { + "Authorization": "Bearer fresh-token" + } + + +def test_hydroserver_sends_no_authorization_when_provider_returns_none( + fake_session_factory, +): + hs = client_module.HydroServer(host="https://example.com", access_token=lambda: None) + fake_session_factory[0].queue("get", FakeResponse()) + + hs.request("get", "/api/ogc/collections/workspaces/items") + + assert fake_session_factory[0].last_kwargs["headers"] == {} + + +def test_hydroserver_keeps_caller_authorization_header(fake_session_factory): + hs = client_module.HydroServer(host="https://example.com", access_token="token-1") + fake_session_factory[0].queue("get", FakeResponse()) + + hs.request( + "get", + "/api/ogc/collections/workspaces/items", + headers={"authorization": "Bearer caller-token", "Accept": "text/csv"}, + ) + + assert fake_session_factory[0].last_kwargs["headers"] == { + "authorization": "Bearer caller-token", + "Accept": "text/csv", + } + + +@pytest.mark.parametrize( + "credentials", + [ + {"apikey": "hs_test_api_key"}, + {"email": "user@example.com", "password": "secret"}, + ], +) +def test_hydroserver_rejects_access_token_with_other_credentials( + fake_session_factory, credentials +): + with pytest.raises(ValueError, match="Provide only one of"): + client_module.HydroServer( + host="https://example.com", access_token="token-1", **credentials + ) + + def test_hydroserver_retries_connection_errors_with_same_basic_auth( fake_session_factory, monkeypatch ):