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
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ vi.mock('@hydroserver/client', () => {
}
})

vi.mock('@/config/settings', () => ({
settings: {
authenticationConfiguration: { csrfCookieName: 'hs_csrftoken' },
},
}))

vi.mock('@/composables/useVocabulary', () => ({
useVocabularyStore: () => ({
fetchAllVocabularies: fetchAllVocabulariesMock,
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions apps/data-management/src/bootstrap/appInitialization.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -71,6 +72,7 @@ export function startAppInitialization() {

hydroServerInitializationPromise = createHydroServer({
host: hydroServerHost,
csrfCookieName: settings.authenticationConfiguration.csrfCookieName,
})
.then(() => {
isHydroServerReady.value = true
Expand Down
1 change: 1 addition & 0 deletions apps/data-management/src/config/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions apps/data-management/src/models/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface Provider {

interface AuthenticationConfiguration {
hydroserverSignupEnabled: boolean
csrfCookieName: string
providers: Provider[]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <access token>
```

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions apps/docs/docs/user-guides/how-to/using-the-python-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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**

Expand Down
17 changes: 16 additions & 1 deletion apps/qc-app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions django/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 8 additions & 0 deletions django/core/iam/auth/oidc_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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())

Expand Down
1 change: 1 addition & 0 deletions django/hydroserver/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
1 change: 1 addition & 0 deletions django/interfaces/web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions django/tests/hydroserver/test_oidc_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------------------


Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions packages/hydroserver-ts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 20 additions & 1 deletion packages/hydroserver-ts/src/api/HydroServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
QualityControlOperationService,
QualityControlSessionService,
} from './services/quality-control.service'
import type { AccessTokenProvider } from './requestInterceptor'

export type AuthTuple = [string, string]

Expand All @@ -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<HydroServerOIDCOptions>
readonly accessTokenProvider?: AccessTokenProvider

private _workspaces?: WorkspaceService
private _monitoringSites?: MonitoringSiteService
Expand Down Expand Up @@ -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`
Expand All @@ -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<HydroServer> {
Expand Down
Loading
Loading