Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
43 changes: 43 additions & 0 deletions docs/develop/plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,49 @@ wss.on('upgrade', (request, socket, head) => {

Authentication is the plugin's responsibility. The upgrade request carries the same cookies and headers as any other request to the server, so a plugin can apply its own checks before accepting a connection.

### Authentication providers

A plugin can add a login method to the server's login page by registering a [passport](https://www.passportjs.org/) strategy with `app.registerAuthenticationProvider()`. Any redirect-style strategy works: OpenID Connect, OAuth 2.0, GitHub, Google, SAML, ... The server mounts the strategy at `/signalk/v1/auth/{id}/login` and `/signalk/v1/auth/{id}/callback`, hands it an encrypted cookie session for the round trip, and turns the identity the strategy verified into a local Signal K user with the regular session cookies. Users created this way appear in the admin UI with the provider name and can be managed like any other user.

The strategy's verify callback must pass an `ExternalIdentity` as the passport user:

- `subject` (required): stable id of the user at the provider; together with `issuer` (optional) it identifies the local user record
- `username`: preferred local username for a new user; a taken name gets a suffix
- `permission`: `readonly` | `readwrite` | `admin`, applied on every login when present
- `email`, `name`: shown in the admin UI

_Example:_

```javascript
const { Strategy: GitHubStrategy } = require('passport-github2')

plugin.start = (options) => {
app.registerAuthenticationProvider({
id: 'github',
name: 'Sign in with GitHub',
strategy: new GitHubStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: `${options.serverUrl}/signalk/v1/auth/github/callback`
},
(accessToken, refreshToken, profile, done) =>
done(null, {
subject: profile.id,
username: profile.username,
permission: options.admins.includes(profile.username)
? 'admin'
: 'readonly'
})
)
})
}
```

Optional settings: `autoCreateUsers: false` rejects identities without a local user; `autoLogin: true` sends visitors of the login page straight to the provider; `authenticateOptions` are passed to `passport.authenticate()` on the login leg (e.g. `scope`); `logoutUrl(req, postLogoutRedirect)` lets `/signalk/v1/auth/{id}/logout` end the session at the provider too. Registration requires security to be enabled and the provider is removed automatically when the plugin stops.

The cookie carrying the strategy's state between the two legs is `SameSite=Lax`, so the provider must return the browser to the callback with a top-level GET (the default for OAuth 2.0 and OpenID Connect); cross-site `POST` callbacks such as `response_mode=form_post` or SAML bindings do not see it. `POST` callbacks are routed to the strategy for strategies that read the parsed `req.body`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

---

## Add an OpenAPI Definition
Expand Down
62 changes: 33 additions & 29 deletions docs/oidc.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,27 +297,19 @@ This is useful when:

## Security Considerations

### PKCE (Proof Key for Code Exchange)
The OIDC login is one of the server's [authentication providers](develop/plugins/README.md#authentication-providers): a [passport](https://www.passportjs.org/) strategy from [openid-client](https://github.com/panva/openid-client), the certified OpenID Connect relying-party library. Protocol details are handled there:

Signal K uses PKCE for all OIDC flows, providing protection against authorization code interception attacks. This is automatic and requires no configuration.
- **PKCE** (S256) on every authorization request, protecting the authorization code against interception. No configuration needed.
- **Nonce** bound to the login attempt and verified in the ID token, preventing token replay.
- **ID token validation**: signature against the provider's JWKS, issuer, audience, expiry (with 5 minutes of clock tolerance) and nonce.
- **Userinfo** claims are only accepted when their `sub` matches the ID token, and only profile claims (`email`, `name`, `preferred_username`, groups) are taken from them.
- **Redirect URI** and post-logout redirect are derived from the configured `redirectUri`, never from the request's `Host` header.

### State Parameter

A cryptographically random state parameter prevents CSRF attacks during the OAuth flow. The state is stored in an encrypted, HTTP-only cookie.

### Token Validation

ID tokens are validated by:

- Verifying the signature against the provider's JWKS
- Checking the issuer matches the configured issuer
- Verifying the audience contains the client ID
- Validating the token is not expired
- Verifying the nonce matches (prevents replay attacks)
The state kept between the login redirect and the callback (PKCE verifier, nonce) lives in an encrypted, HTTP-only cookie scoped to `/signalk/v1/auth` that expires after 10 minutes. There is no server-side session store.

### HTTPS Requirement

For production use, always run Signal K behind HTTPS. OIDC cookies are marked as `Secure` when accessed over HTTPS.
For production use, always run Signal K behind HTTPS. Cookies are marked as `Secure` when accessed over HTTPS.

## Troubleshooting

Expand Down Expand Up @@ -354,15 +346,15 @@ For production use, always run Signal K behind HTTPS. OIDC cookies are marked as
3. Ensure the user is in a group listed in `adminGroups`
4. Some providers require explicit configuration to include groups in tokens

### "State mismatch" or "Invalid state"
### "Unable to verify authorization request state"

**Cause**: The OIDC flow state was lost or expired.
**Cause**: The cookie holding the login attempt's state was lost or expired.

**Solutions**:

- Ensure cookies are enabled in the browser
- Check if a reverse proxy is stripping cookies
- The state cookie expires after 10 minutes; restart the login flow
- The cookie expires after 10 minutes; restart the login flow

### Login redirects but user not authenticated

Expand All @@ -378,41 +370,53 @@ For production use, always run Signal K behind HTTPS. OIDC cookies are marked as

### Login Endpoint

```
```text
GET /signalk/v1/auth/oidc/login
```

Initiates the OIDC login flow. Redirects to the identity provider.

Query parameters:

- `returnTo` (optional): URL to redirect after successful login
- `redirect` (optional): same-origin relative path to return to after a successful login

### Callback Endpoint

```
```text
GET /signalk/v1/auth/oidc/callback
```

Handles the OIDC callback from the identity provider. Not called directly by users.
Handles the OIDC callback from the identity provider. Not called directly by users. A failed login redirects to `/admin/#/login?authError=true&message=...`.

### Login Status
### Logout Endpoint

```text
GET /signalk/v1/auth/oidc/logout
```

Clears the Signal K session and, when the provider publishes an `end_session_endpoint`, redirects there so the user is logged out of the identity provider too. Accepts the same `redirect` parameter as the login endpoint.

### Login Status

```text
GET /skServer/loginStatus
```

Returns the current authentication status including OIDC configuration:
Returns the current authentication status including the available login methods:

```json
{
"status": "loggedIn",
"username": "oidc-user@example.com",
"userLevel": "admin",
"oidcEnabled": true,
"oidcAutoLogin": false,
"oidcLoginUrl": "/signalk/v1/auth/oidc/login",
"oidcProviderName": "Corporate SSO"
"authProviders": [
{
"id": "oidc",
"name": "Corporate SSO",
"loginUrl": "/signalk/v1/auth/oidc/login",
"autoLogin": false
}
]
}
```

Expand Down
116 changes: 59 additions & 57 deletions docs/security-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@ consists of:
for security implementations
2. **Dummy Security** (`src/dummysecurity.ts`) - No-op implementation when
security is disabled
3. **Token Security** (`src/tokensecurity.js`) - Full implementation with JWT-
3. **Token Security** (`src/tokensecurity.ts`) - Full implementation with JWT-
based authentication
4. **OIDC Module** (`src/oidc/`) - OpenID Connect authentication support
4. **Authentication Providers** (`src/auth/`) - passport-based login methods
(redirect flows) that plugins can extend
5. **OIDC Module** (`src/oidc/`) - the built-in OpenID Connect provider

## Component Diagram

Expand All @@ -27,7 +29,7 @@ consists of:
│ Signal K Server │
│ │
│ ┌─────────────────────┐ ┌─────────────────────────────┐ │
│ │ security.ts │ │ tokensecurity.js │ │
│ │ security.ts │ │ tokensecurity.ts │ │
│ │ │ │ │ │
│ │ - SecurityStrategy │◄──────│ - login/logout routes │ │
│ │ interface │ │ - JWT token management │ │
Expand All @@ -36,28 +38,21 @@ consists of:
│ └─────────────────────┘ │ - ACL enforcement │ │
│ ▲ └──────────────┬──────────────┘ │
│ │ │ │
│ │ │ Dependencies │
│ ┌────────┴────────┐ ▼ │
│ ┌────────┴────────┐ ▼ │
│ │ dummysecurity.ts │ ┌─────────────────────────────┐ │
│ │ │ │ src/oidc/ │ │
│ │ │ │ src/auth/ │ │
│ │ - No-op impl │ │ │ │
│ │ - Used when │ │ ┌─────────────────────────┐ │ │
│ │ security │ │ │ oidc-auth.ts │ │ │
│ │ disabled │ │ │ │ │ │
│ └──────────────────┘ │ │ - registerOIDCRoutes() │ │ │
│ │ │ - findOrCreateOIDCUser()│ │ │
│ │ └────────────┬────────────┘ │ │
│ │ │uses │ │
│ │ ┌────────────┴────────────┐ │ │
│ │ │ Helper Modules │ │ │
│ │ │ - config.ts │ │ │
│ │ │ - state.ts │ │ │
│ │ │ - pkce.ts │ │ │
│ │ │ - discovery.ts │ │ │
│ │ │ - authorization.ts │ │ │
│ │ │ - token-exchange.ts │ │ │
│ │ │ - id-token-validation.ts│ │ │
│ │ └─────────────────────────┘ │ │
│ │ - Used when │ │ - providers.ts: registry + │ │
│ │ security │ │ /signalk/v1/auth/:id/* │ │
│ │ disabled │ │ - provisioning.ts: identity │ │
│ └──────────────────┘ │ -> local user │ │
│ │ - handshake-session.ts │ │
│ └──────────────▲──────────────┘ │
│ │ registers │
│ ┌──────────────┴──────────────┐ │
│ │ src/oidc/ (built-in) │ │
│ │ plugins (any passport │ │
│ │ strategy) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
Expand All @@ -69,12 +64,13 @@ a security implementation must provide:

## Token Security Implementation

`tokensecurity.js` is the production security implementation. It provides:
`tokensecurity.ts` is the production security implementation. It provides:

### Authentication Flow

1. **Local Login**: Username/password via `/login` or `/signalk/v1/auth/login`
2. **OIDC Login**: Delegates to `oidc-auth.ts` for SSO authentication
2. **Provider Login**: Any registered authentication provider (OIDC or a
plugin's passport strategy) via `/signalk/v1/auth/<id>/login`
3. **Device Access Requests**: Devices can request access tokens

### Session Management
Expand All @@ -92,49 +88,55 @@ Session cookie helpers ensure consistent security settings:

The server implements a sliding session window: when a JWT token is past the midpoint of its lifetime, the next HTTP request silently replaces the cookie with a freshly issued token. This keeps active users logged in indefinitely while inactive sessions still expire after the configured duration.

## OIDC Integration
## Authentication Providers

The OIDC module provides OpenID Connect authentication for Single Sign-On.
`src/auth/providers.ts` keeps a registry of login methods, each backed by a
[passport](https://www.passportjs.org/) strategy, on a private passport
instance. `SecurityStrategy.registerAuthenticationProvider()` (exposed to
plugins as `app.registerAuthenticationProvider()`) adds one at runtime.

### Authentication Flow

1. User clicks "SSO Login"
2. Server creates state, stores in encrypted cookie
3. Redirects to OIDC provider's authorization endpoint
4. User authenticates with provider
5. Provider redirects back with authorization code
6. Server exchanges code for tokens
7. Server validates ID token
8. Server creates/updates local user record
9. Server issues local JWT session
1. User picks a provider on the login page (`loginStatus.authProviders`)
2. `GET /signalk/v1/auth/<id>/login?redirect=` stores the return path in the
handshake session and runs `passport.authenticate(<id>)`; the strategy
redirects to the identity provider
3. Provider redirects back to `GET|POST /signalk/v1/auth/<id>/callback`
4. The strategy verifies the response and its verify callback returns an
`ExternalIdentity` (subject, issuer, username, permission, email, name)
5. `provisioning.ts` finds the local user by provider + issuer + subject, or
creates one (persisting `security.json` before the record becomes
visible; calls are serialized so concurrent first logins cannot create
duplicates), and refreshes permission and identity details
6. `tokensecurity` issues the regular JWT session cookies and redirects to
the stored return path

Passport strategies keep their state (PKCE verifier, nonce) in `req.session`.
`handshake-session.ts` provides that as an AES-256-GCM encrypted cookie
scoped to `/signalk/v1/auth`, keyed from the master secret; there is no
server-side session store.

### Logout Flow

The `/signalk/v1/auth/oidc/logout` endpoint supports logging out from both
Signal K and the identity provider:
`GET /signalk/v1/auth/<id>/logout` clears the local session cookies and asks
the provider for a logout URL. The OIDC provider returns the identity
provider's `end_session_endpoint` with `post_logout_redirect_uri` derived
from the configured `redirectUri`, so the user is logged out of both.

1. User clicks "Logout"
2. Server clears local session cookies
3. If provider supports `end_session_endpoint`:
- Redirects to provider's logout endpoint with `post_logout_redirect_uri`
- Provider logs out the user and redirects back
4. If provider doesn't support logout, redirects locally
## OIDC Provider

This ensures users are logged out of both Signal K and the identity provider.
`src/oidc/` is the built-in OpenID Connect provider, registered through the
same registry as plugin providers:

### Helper Modules
| Module | Responsibility |
| ----------------------- | ------------------------------------------------------ |
| `config.ts` | Parse and validate OIDC config (env + security.json) |
| `provider.ts` | Strategy from `openid-client/passport`, lazy discovery |
| `permission-mapping.ts` | Map groups claim to Signal K permission |
| `oidc-admin.ts` | Admin API: GET/PUT `/security/oidc`, connection test |

Each OIDC helper module has a single responsibility:

| Module | Responsibility |
| ------------------------ | ------------------------------ |
| `config.ts` | Parse and validate OIDC config |
| `state.ts` | Create/encrypt/decrypt state |
| `pkce.ts` | Generate PKCE code verifier |
| `discovery.ts` | Fetch OIDC provider metadata |
| `authorization.ts` | Build authorization URLs |
| `token-exchange.ts` | Exchange code for tokens |
| `id-token-validation.ts` | Validate ID token signatures |
Discovery runs on the first login attempt (the identity provider may boot
after Signal K) and is cached until the configuration changes.

## Configuration

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,6 @@
"geolib": "^3.3.14",
"get-folder-size": "^5.0.0",
"helmet": "^8.2.0",
"jose": "^6.2.3",
"json-patch": "^0.7.0",
"jsonwebtoken": "^9.0.3",
"lodash": "^4.18.1",
Expand All @@ -119,8 +118,10 @@
"morgan": "^1.11.0",
"ms": "^2.1.2",
"ncp": "^2.0.0",
"on-headers": "^1.1.0",
"openid-client": "^6.8.4",
"ora": "^5.4.1",
"passport": "^0.7.0",
"path-to-regexp": "^0.1.12",
"primus": "^7.3.5",
"prompts": "^2.4.2",
Expand Down Expand Up @@ -176,6 +177,8 @@
"@types/lodash": "^4.17.24",
"@types/mocha": "^10.0.10",
"@types/ncp": "^2.0.8",
"@types/on-headers": "^1.0.4",
"@types/passport": "^1.0.17",
"@types/semver": "^7.7.1",
"@types/split": "^1.0.5",
"@types/swagger-ui-express": "^4.1.8",
Expand Down
13 changes: 9 additions & 4 deletions packages/server-admin-ui/src/dataFetching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,14 @@ const loggedIn: LoginStatus = {
status: 'loggedIn',
authenticationRequired: true,
username: 'admin',
oidcEnabled: true,
oidcLoginUrl: '/signalk/v1/auth/oidc/login'
authProviders: [
{
id: 'oidc',
name: 'SSO Login',
loginUrl: '/signalk/v1/auth/oidc/login',
autoLogin: false
}
]
}

describe('authFetch 401 handling', () => {
Expand All @@ -39,8 +45,7 @@ describe('authFetch 401 handling', () => {
expect(ls.username).toBeUndefined()
// Server settings preserved across the credential expiry.
expect(ls.authenticationRequired).toBe(true)
expect(ls.oidcEnabled).toBe(true)
expect(ls.oidcLoginUrl).toBe('/signalk/v1/auth/oidc/login')
expect(ls.authProviders).toEqual(loggedIn.authProviders)
})

it('does not touch loginStatus on 401 from /signalk/v1/auth/login', async () => {
Expand Down
Loading
Loading