The DingTalk login could not complete. Adding openid to the authorization
request's scope set avoided the nonce at the authorize step but broke the
callback: OAuth2LoginAuthenticationProvider.authenticate returns null when
getScopes() contains "openid", handing the exchange to
OidcAuthorizationCodeAuthenticationProvider, which fails with
invalid_id_token because DingTalk returns no id_token. Neither the token
client nor the user service was ever reached. spring-security-oauth2-jose is
on the runtime classpath, so that provider is registered.
The scope now goes onto the outgoing authorization URI directly, leaving
getScopes() empty. Both openid-keyed mechanisms are then avoided: no nonce,
because the registration still declares no scope in configuration, and no
OIDC routing, because the request carries no openid scope.
The previous test asserted getScopes() contains "openid" -- the exact state
that breaks the callback -- so it locked the bug in. It now asserts the
inverse, and restoring the old implementation makes it fail.
Also switches the registration from client-authentication-method: none to
client-secret-post. "none" made Spring apply PKCE and emit a code_challenge
that DingTalkTokenResponseClient cannot answer, since its JSON token request
sends no code_verifier. It was also semantically wrong: DingTalk is a
confidential client that carries its secret in the request body.
Verified against a local staging instance: the authorization URI now carries
scope=openid with no nonce and no code_challenge, and a callback with a fake
code fails in the token exchange with no OIDC provider involvement in the
logs.
Drops SUBJECT_ATTRIBUTE, which lost its last reference when the user service
stopped pre-resolving the subject.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Two gaps from reviewing this batch against the Feishu adapter it mirrors.
The token exchange had no response size limit while the userinfo call did,
so the same hostile or misconfigured endpoint was bounded on one call and
unbounded on the other. Adds the same 64 KB cap through a RestTemplate
interceptor, which keeps the existing tests working against an injected
template. buildRestTemplate becomes package-visible so one test can exercise
the production template, cap included; removing the interceptor makes that
test fail.
A missing unionId threw without logging, unlike the equivalent Feishu
branch. This is a reachable failure -- DingTalk omits unionId for some app
configurations -- and an operator seeing every login rejected needs to know
why. Logs the claim name only, which says nothing about the user.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Adds the DingTalk credentials to every path that actually delivers
configuration: compose.release.yml (which has no env_file, so variables must
be listed explicitly), the Helm secret template and values, the k8s
deployment and its secret example. validate-release-config.sh gains DingTalk
in its provider loop, so a half-configured pair is rejected the same way.
Documents the three-stage strategy contract in the authentication design: a
table mapping each deviation -- authorize parameters, token exchange,
userinfo loading -- to its interface and current implementations, plus the
rule that a provider must never make account decisions itself.
Deployment notes and both FAQs now cover DingTalk, including the shared trap
with Feishu: their emails are admin-recorded and never confirmed, so
emailVerified is always false and an EMAIL_DOMAIN access policy would reject
every login through either provider.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Adds DingTalk (钉钉) as a public sign-in option: it authenticates a SkillHub
platform account and nothing more. No Organization membership, no directory
sync, no Namespace grants.
DingTalk deviates from standard OAuth at all three stages, one strategy each:
- authorize: its endpoint wants scope=openid, but declaring that scope in
configuration makes Spring treat the registration as OIDC and attach a
nonce, which DingTalk rejects. The scope is added by
DingTalkAuthorizationRequestCustomizer instead, keeping this a plain OAuth2
client. A test asserts the scope is present and the nonce is not.
- token: credentials go in a JSON body rather than a form, handled by
DingTalkTokenResponseClient.
- userinfo: the token travels in x-acs-dingtalk-access-token rather than
Authorization: Bearer.
Subject and email semantics, which decide whether a login can reach an
existing account:
- unionId is the only accepted subject. DingTalk also returns openId and
userId, but they must not act as fallbacks: openId is scoped per app and
userId per organization, so a login falling back to either would bind a
different identity than a later login carrying unionId, splitting one
person across two platform accounts.
- A blank or missing unionId fails the login.
- emailVerified is always false. DingTalk returns the email an organization
admin recorded without attesting the user controls it.
The userinfo service only fetches attributes; account matching, provisioning
and session creation stay with the unified identity core. The reference
implementation called OAuthLoginFlowService.authenticate() from inside
loadUser, which decided the account before the core's gate ran.
Operational bounds match the Feishu adapter: connect and read timeouts, a
64 KB response cap, error descriptions and logs carrying only the exception
class or provider error code, and no logging in the claims extractor.
Unused PII is dropped rather than carried into the principal -- notably
mobile and stateCode.
Adds ProviderStrategyWiringTest, which loads the real application context.
The unit tests call package-visible constructors and so cannot catch Spring
wiring faults; a component with two constructors and no @Autowired marker
unit-tests green and then fails at startup. That happened during this work.
Adapted from the implementation in #467 by @konglong87, re-extracted onto
current main with the subject, structure and bounds changes above.
Part of R1-A2 (public Provider adapters) per
openspec/changes/enterprise-identity-platform/rollout-plan.md.
Co-authored-by: konglong87 <konglong87@users.noreply.github.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Extends the per-provider strategy pattern from the userinfo step to the two
earlier stages of the authorization-code flow, so a provider whose endpoints
deviate from the standard contract needs no branch in shared code:
- ProviderTokenResponseClient for a non-standard token exchange, dispatched
by DispatchingTokenResponseClient because Spring's tokenEndpoint accepts
only one client
- ProviderAuthorizationRequestCustomizer for authorization parameters,
dispatched through the resolver's existing customizer hook
Registrations without an override keep the standard Spring behaviour.
Together with ProviderOAuth2UserService this covers all three stages where
a provider can deviate: authorize, token, userinfo. Account decisions stay
outside these hooks, in the unified identity core.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* feat(auth): let providers override OAuth userinfo loading
Some providers do not return a flat, standard userinfo payload, so
DefaultOAuth2UserService cannot read them. Add ProviderOAuth2UserService
so a provider can claim its own registration id and supply the loading
step, while everything after it stays shared.
The override runs inside the RemoteIdentityIoExecutor boundary added in
R1-A, so a provider's HTTP call does not hold the surrounding
transaction open. Registrations without an override keep using the
default user service unchanged.
Part of R1-A2 (public Provider adapters) per
openspec/changes/enterprise-identity-platform/rollout-plan.md.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* feat(auth): add Feishu as a public login provider
Adds Feishu (Lark) as a public sign-in option: it authenticates a
SkillHub platform account and nothing more. No Organization membership,
no directory sync, no Namespace grants.
Feishu deviates from standard OAuth in two ways this handles:
its userinfo response is wrapped in a {code, msg, data} envelope, and it
reports errors with HTTP 200. FeishuOAuth2UserService unwraps that
envelope into flat attributes; FeishuClaimsExtractor maps them to the
shared OAuthClaims, so account decisions still run through the unified
identity core added in R1-A.
Subject and email semantics, which decide whether a login can reach an
existing account:
- open_id is the only subject. union_id stays in extra rather than
acting as a fallback: a subject that can change between logins would
split one person across two platform accounts. Promoting union_id
later needs an explicit alias migration.
- A blank or missing open_id fails the login instead of binding the
literal string "null".
- emailVerified is always false. Feishu emails are imported by an
organization admin and never confirmed with the user, so they carry no
verification signal and cannot be used to join an existing account.
Operational bounds: the userinfo call has connect and read timeouts so an
unresponsive Feishu endpoint cannot hold a login thread, and the
OAuth2Error description carries only the provider error code, because an
upstream message can quote the request URI and with it the access token.
Like the GitHub and GitLab extractors, the claims extractor logs nothing.
The login button follows the existing config-driven catalog: with no
client id configured, /api/v1/auth/methods does not list Feishu and no
button renders. No frontend code change is needed; the icon resolves by
provider name.
Adapted from the implementation in #696 by @yhd4711499, re-extracted onto
current main with the subject, logging and timeout changes above.
Part of R1-A2 (public Provider adapters) per
openspec/changes/enterprise-identity-platform/rollout-plan.md.
Co-authored-by: yhd4711499 <yhd4711499@users.noreply.github.com>
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(auth): bound Feishu userinfo response and stop subject leaking into displayName
Three defects found reviewing this batch against the R1-A2 spec.
Response size limit. The spec's scope line asks for "远程 I/O 超时与响应大小
限制"; only the timeouts were implemented, so a misconfigured or hostile
OAUTH2_FEISHU_BASE_URI could stream an unbounded body into the parser.
Reads at most 64 KB before parsing, mirroring the 10 MB cap the shared
WebClientConfig already applies. Uses InputStream.readNBytes rather than
adding commons-io or guava, neither of which skillhub-auth declares.
Synthesized displayName. Falling back to "feishu-<open_id>" wrote the
external subject into UserAccount.displayName and into
UserActivatedEvent, carrying it somewhere event consumers may log it --
against the R1-A gate that logs must not contain the subject. Now stops
at name -> en_name like the GitHub and GitLab extractors.
Unused mobile attribute. A phone number was extracted into the principal
attributes and read by nothing. It is PII the spec did not ask for and it
widened the redaction surface for free.
Also drops a constructor overload that only passed List.of() through, and
a test that duplicated the blank-subject path.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* docs(auth): document the provider adapter contract and Feishu operator setup
AGENTS.md and CONTRIBUTING.md both require docs updates when auth flows or
deployment config change; this batch changed both and touched no docs.
03-authentication-design.md described adding a provider as "branch on
registrationId inside CustomOAuth2UserService", which the
ProviderOAuth2UserService strategy supersedes. Rewrites that recipe:
register an OAuthClaimsExtractor bean per provider, add a
ProviderOAuth2UserService only when the userinfo response is non-standard,
and note that the login page needs no code change. Also records the
provider-side obligations that are easy to get wrong -- stable subject with
no fallback, emailVerified only on proven ownership, bounded remote calls,
no subject in logs -- and un-comments the config example, which still
listed GitLab as a future possibility.
faq.md told operators to delete "the github and gitlab blocks" to hide SSO
buttons. That advice was already incomplete and gets worse per provider, so
it now explains the config-driven mechanism: an empty client id keeps the
entry off the login page, no file edit needed.
09-deployment.md listed only the GitHub credentials. Adds GitLab and Feishu,
and flags a deployment trap: Feishu emails are admin-imported so
emailVerified is always false, and skillhub.access-policy.mode=EMAIL_DOMAIN
denies every unverified email, which would reject all Feishu logins.
Squares the Feishu logo viewBox. It was 407.87x324.19 while login-button
renders it in a square w-5 h-5 box, so the mark was distorted.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* feat(deploy): wire Feishu credentials into the release surfaces
.env.release.example advertised OAUTH2_FEISHU_* knobs that no deployment
path could actually deliver. compose.release.yml has no env_file, so every
variable must be listed explicitly, and the Helm chart and k8s base only
mapped the GitHub secret keys. Setting the documented variables therefore
did nothing.
Adds Feishu to compose.release.yml, the Helm secret template and values,
the k8s deployment and its secret example. GitLab had the identical gap, so
it is wired at the same time rather than leaving the example file half true.
validate-release-config.sh only checked that GitHub's id and secret appear
together. A half-configured provider renders a login button whose exchange
then fails, so the check now loops over all three providers. Its test gained
both-directions cases per provider plus a fully configured pass; reverting
the loop to GitHub-only makes them fail.
Also adds the provider's only failure log. Nothing downstream records a
Feishu userinfo failure -- OAuth2LoginFailureHandler does not log either --
so the previous code was silent on error. Logs the exception class and
Feishu's own error code, never the upstream msg, which can quote the access
token; a test asserts the code is present and the token is not.
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(auth): use JSON token exchange for Feishu OAuth
Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(auth): preserve Feishu OAuth browser redirect
Add safe phase-level OAuth diagnostics and redact callback credentials from request logs.
Made-with: Proma
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* docs(deploy): clarify Feishu OAuth configuration and validation
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(deploy): pass Feishu redirect URI through releases
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(deploy): pass S3 chunked encoding setting
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(deploy): preserve default Feishu callback derivation
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---------
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: yhd4711499 <yhd4711499@users.noreply.github.com>
* feat(auth): expose skill lifecycle routes to API tokens
With an API token, v0.2.19 can remove a whole skill (DELETE
/api/v1/skills/{ns}/{slug} with skill:delete) but cannot archive or
unarchive a skill, nor delete a single draft/rejected version. Those
three routes are opened by AUTHORIZATION_POLICIES (authenticated
fallback) yet have no entry in API_TOKEN_POLICIES, so a bearer request
falls through to "unsupported" and is rejected with 403.
That contradicts the contract written above SESSION_ONLY_ROUTES in
RouteSecurityPolicyRegistry: bearer tokens are rejected on exactly the
listed session-only routes and nowhere else, and anything else the
authorization list opens must be reachable with a token holding the
required scope.
Add API-token policies for both the /api/v1 and /api/web prefixes that
SkillLifecycleController serves:
- POST .../skills/{ns}/{slug}/archive and .../unarchive require
skill:publish. They are owner-level operations, gated by the same
assertCanManageLifecycle check as publishing, so they sit at the same
scope tier.
- DELETE .../skills/{ns}/{slug}/versions/{version} requires
skill:delete, matching the existing whole-skill delete.
Whole-skill DELETE on /api/web stays session-only as documented; the
new version-delete pattern does not overlap it. No scope allow-list
exists outside the registry (TokenController and ApiTokenScopeService
accept any scope string), so no other change is needed for tokens to
carry these scopes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdnYX4jTS3JwMMP9JCGxzU
* feat(skills): let skill owners yank a published version
Yanking a published version is only available through
POST /api/v1/admin/skills/versions/{versionId}/yank, which is
session-only (SESSION_ONLY_ROUTES covers /api/v1/admin/**) and requires
SKILL_ADMIN or SUPER_ADMIN. A skill owner therefore cannot pull a
broken release themselves, neither from the web surface nor from a
script holding an API token.
In package registries yank is an act of the publisher: `cargo yank`
and PyPI's "yank release" are performed by the package owner, not by a
registry admin, because the goal is to stop new installs of a bad
release while keeping the artifact available for lock files. SkillHub
already lets owners archive, unarchive, rerelease and delete draft
versions through SkillLifecycleController under the
assertCanManageLifecycle rule (owner, or namespace ADMIN/OWNER); yank
belongs on the same surface with the same rule.
Changes:
- SkillGovernanceService: add an owner-checked yankVersion(skill,
version, actor, roles, ip, ua, reason) that runs
assertCanManageLifecycle and then the same yank logic as the admin
variant, now shared in yankVersionInternal. The admin entry point is
unchanged for AdminSkillController.
- SkillLifecycleAppService / GovernanceWorkflowAppService: resolve
skill and version by namespace/slug/version, delegate to the new
domain method, and return SkillLifecycleMutationResponse with action
YANK and the resulting version status. The YANK_SKILL_VERSION audit
record and SkillVersionYankedEvent are emitted by the domain service
exactly as for the admin path.
- SkillLifecycleController: POST /{namespace}/{slug}/versions/{version}/yank
on both /api/v1/skills and /api/web/skills, optional body
AdminSkillActionRequest (reason).
- RouteSecurityPolicyRegistry: require skill:yank for the new route on
both prefixes, so tokens can reach it as the SESSION_ONLY_ROUTES
comment promises for every route the authorization list opens. The
admin yank stays session-only. No allow-list of scopes exists outside
the registry; the docs' scope enumeration is updated to include
skill:yank.
- Tests: RouteSecurityPolicyRegistryTest (scope required on both
prefixes, admin route still closed), SkillGovernanceServiceTest
(owner and namespace ADMIN allowed, MEMBER forbidden, unpublished
rejected), SkillLifecycleControllerTest (envelope with and without
body).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdnYX4jTS3JwMMP9JCGxzU
* fix(auth): complete API token lifecycle access
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* fix(skills): align owner lifecycle token access
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---------
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: Thiago Nascimento Nogueira <thiago.nascimento.nogueira@emeal.nttdata.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
* feat(auth): expose skill lifecycle routes to API tokens
With an API token, v0.2.19 can remove a whole skill (DELETE
/api/v1/skills/{ns}/{slug} with skill:delete) but cannot archive or
unarchive a skill, nor delete a single draft/rejected version. Those
three routes are opened by AUTHORIZATION_POLICIES (authenticated
fallback) yet have no entry in API_TOKEN_POLICIES, so a bearer request
falls through to "unsupported" and is rejected with 403.
That contradicts the contract written above SESSION_ONLY_ROUTES in
RouteSecurityPolicyRegistry: bearer tokens are rejected on exactly the
listed session-only routes and nowhere else, and anything else the
authorization list opens must be reachable with a token holding the
required scope.
Add API-token policies for both the /api/v1 and /api/web prefixes that
SkillLifecycleController serves:
- POST .../skills/{ns}/{slug}/archive and .../unarchive require
skill:publish. They are owner-level operations, gated by the same
assertCanManageLifecycle check as publishing, so they sit at the same
scope tier.
- DELETE .../skills/{ns}/{slug}/versions/{version} requires
skill:delete, matching the existing whole-skill delete.
Whole-skill DELETE on /api/web stays session-only as documented; the
new version-delete pattern does not overlap it. No scope allow-list
exists outside the registry (TokenController and ApiTokenScopeService
accept any scope string), so no other change is needed for tokens to
carry these scopes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DdnYX4jTS3JwMMP9JCGxzU
* fix(auth): complete API token lifecycle access
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
---------
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Co-authored-by: Thiago Nascimento Nogueira <thiago.nascimento.nogueira@emeal.nttdata.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
Users hand-writing a compose file for intranet installs hit database init and
dependency-order errors. Point them at the official runtime.sh entrypoint,
including the --aliyun mirror for networks that cannot reach ghcr.io and the
mirror-runtime-images.sh + --mirror-registry path for air-gapped setups.
Applied to both the zh and en reference FAQ.
Signed-off-by: FenjuFu <fufenjupku@gmail.com>