feat(auth): sign in with Steam (MODULE_API 1.17.0)

Steam is a built-in provider beside Google and Discord (STEAM_SIGNIN.md,
D392-D396). OpenID 2.0: the assertion is verified server to server with
check_authentication, return_to must match byte for byte, and the claimed id
must be a 17-digit SteamID64. A Steam Web API key is required to enable it
(D394); persona name and avatar are stored on the identity and refreshed at
each sign-in, and a failed lookup never blocks one. Steam never signs anybody
up (D395), on the web or the mobile bridge. Several Steam accounts may be
linked; DELETE /auth/me/account/identities/:provider takes ?id= to unlink one.

MODULE_API 1.17.0: ctx.identities.list, api.onIdentityLinking (refuse-only,
fails closed, module text never shown) and api.onIdentityLinked. No unlink
hook (D393). Schema additive; OpenAPI, routes and engagement manifests
regenerated with no modules installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-19 14:02:49 -05:00
parent 6ba2ed90ed
commit 5e398d8acd
30 changed files with 1346 additions and 126 deletions

View File

@@ -13089,7 +13089,7 @@
"Auth · Me"
],
"summary": "Unlink an SSO identity (self)",
"description": "",
"description": "Removes every identity of the provider, or with ?id= the one identity named (a user may link several Steam accounts). Only the sign-in identity goes: a game module link made from it stays (STEAM_SIGNIN.md D393).",
"parameters": [
{
"name": "provider",
@@ -13099,6 +13099,15 @@
"type": "string"
},
"description": "Provider id."
},
{
"name": "id",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "One identity to unlink (its id from GET /auth/me/account/identities)."
}
],
"responses": {
@@ -13113,7 +13122,14 @@
}
},
"400": {
"description": "Bad Request"
"description": "Validation error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"401": {
"description": "Unauthorized"
@@ -15467,7 +15483,7 @@
"Auth · SSO"
],
"summary": "OAuth redirect target — completes login or linking",
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked).",
"description": "The provider redirects here with code + state. On success sets the session cookie (login) or links the identity (link), then 302-redirects into /admin. Login is link-only: unknown identities are refused (sso_error=not_linked). Steam (OpenID 2.0) sends openid.* fields instead of code; the server verifies them with Steam (check_authentication) before anything else, and Steam never signs anybody up (STEAM_SIGNIN.md D395). A module may refuse a link (link_error=in_use or refused; MODULE_API 1.17.0).",
"parameters": [
{
"name": "provider",
@@ -15476,14 +15492,7 @@
"schema": {
"type": "string"
},
"description": "Provider id (e.g. google, discord)."
},
{
"name": "error",
"in": "query",
"schema": {
"type": "string"
}
"description": "Provider id (e.g. google, discord, steam)."
},
{
"name": "code",
@@ -15492,7 +15501,7 @@
"schema": {
"type": "string"
},
"description": "OAuth authorization code."
"description": "OAuth authorization code (OAuth2/OIDC providers)."
},
{
"name": "state",
@@ -15501,7 +15510,50 @@
"schema": {
"type": "string"
},
"description": "OAuth state (matched against the tx cookie)."
"description": "OAuth state (matched against the tx cookie). For Steam it rides inside openid.return_to and comes back as this parameter."
},
{
"name": "error",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "OAuth error (the user declined, or the IdP refused)."
},
{
"name": "openid.mode",
"in": "query",
"required": false,
"schema": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"enum": {
"type": "array",
"example": [
"id_res",
"cancel"
],
"items": {
"type": "string"
}
}
}
},
"description": "Steam only: id_res with an assertion, or cancel. The other openid.* fields of the assertion (ns, op_endpoint, claimed_id, identity, return_to, response_nonce, assoc_handle, signed, sig) are passed back to Steam to verify."
},
{
"name": "openid.claimed_id",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Steam only: https://steamcommunity.com/openid/id/<SteamID64>."
}
],
"responses": {
@@ -19827,7 +19879,7 @@
},
"description": {
"type": "string",
"example": "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2')."
"example": "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2')."
},
"example": {
"type": "string",
@@ -19908,6 +19960,7 @@
"example": [
"google",
"discord",
"steam",
"oidc",
"oauth2"
],
@@ -19965,7 +20018,7 @@
},
"description": {
"type": "string",
"example": "Whether a client secret is stored (the secret itself is never returned)."
"example": "Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without."
},
"example": {
"type": "boolean",
@@ -20047,7 +20100,7 @@
},
"description": {
"type": "string",
"example": "True for the fixed google/discord providers."
"example": "True for the fixed google/discord/steam providers."
},
"example": {
"type": "boolean",
@@ -24740,6 +24793,23 @@
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"description": {
"type": "string",
"example": "Identity id, for unlinking one of several (DELETE ...?id=)."
},
"example": {
"type": "number",
"example": 12
}
}
},
"provider": {
"type": "object",
"properties": {
@@ -24774,6 +24844,65 @@
}
}
},
"subject": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"description": {
"type": "string",
"example": "The user’s own external id at the provider; for steam, the 17-digit SteamID64."
},
"example": {
"type": "string",
"example": "76561197960287930"
}
}
},
"display_name": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Persona name the provider reported (steam only)."
},
"example": {
"type": "string",
"example": "CoolGuy99"
}
}
},
"avatar_url": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "uri"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "Avatar on the provider’s CDN (steam only)."
}
}
},
"linked_at": {
"type": "object",
"properties": {

View File

@@ -294,7 +294,7 @@ const doc = {
name: { type: 'string', example: 'Google' },
icon: {
type: 'string',
description: "Icon hint — the provider kind ('google' | 'discord' | 'oidc' | 'oauth2').",
description: "Icon hint — the provider kind ('google' | 'discord' | 'steam' | 'oidc' | 'oauth2').",
example: 'google',
},
loginUrl: {
@@ -313,17 +313,17 @@ const doc = {
type: 'object',
properties: {
id: { type: 'string', example: 'okta' },
kind: { type: 'string', enum: ['google', 'discord', 'oidc', 'oauth2'], example: 'oidc' },
kind: { type: 'string', enum: ['google', 'discord', 'steam', 'oidc', 'oauth2'], example: 'oidc' },
name: { type: 'string', example: 'Okta' },
enabled: { type: 'boolean', example: true },
clientId: { type: 'string' },
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned).', example: true },
hasSecret: { type: 'boolean', description: 'Whether a client secret is stored (the secret itself is never returned). For steam, the Steam Web API key it cannot be enabled without.', example: true },
authorizeUrl: { type: 'string', format: 'uri' },
tokenUrl: { type: 'string', format: 'uri' },
userinfoUrl: { type: 'string', format: 'uri' },
scopes: { type: 'string', example: 'openid email profile' },
priority: { type: 'integer', example: 10 },
builtin: { type: 'boolean', description: 'True for the fixed google/discord providers.', example: false },
builtin: { type: 'boolean', description: 'True for the fixed google/discord/steam providers.', example: false },
health: { $ref: '#/components/schemas/ProviderHealth' },
},
},
@@ -961,8 +961,12 @@ const doc = {
LinkedIdentity: {
type: 'object',
properties: {
id: { type: 'integer', description: 'Identity id, for unlinking one of several (DELETE ...?id=).', example: 12 },
provider: { type: 'string', example: 'google' },
email: { type: 'string', format: 'email', nullable: true, example: 'user@example.com' },
subject: { type: 'string', description: 'The user’s own external id at the provider; for steam, the 17-digit SteamID64.', example: '76561197960287930' },
display_name: { type: 'string', nullable: true, description: 'Persona name the provider reported (steam only).', example: 'CoolGuy99' },
avatar_url: { type: 'string', format: 'uri', nullable: true, description: 'Avatar on the provider’s CDN (steam only).' },
linked_at: { type: 'string', format: 'date-time' },
},
},