@auth-link/web 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,6 +6,12 @@
6
6
 
7
7
  Responsive dialogs for **AuthLink phone binding and mobile confirmation**, with direct function calls and React / Vue components. Display binding QR codes, synchronized six-digit matching codes, countdowns and confirmation results in any web application. No particular site platform is required.
8
8
 
9
+ ### Go backend + Vue walkthrough
10
+
11
+ Start with [Go + Vue: login, binding and confirmed publication](#go-vue-cms-en) and the `examples/vue-cms.vue`.
12
+
13
+ The flow is site login → Go `/api/session` returns authenticated/csrf → configure the cross-origin client with `csrfToken: () => session.csrf` → create bind/confirm → Go consumes, validates and commits the receipt → Vue dismisses the dialog → a separate business API checks the saved receipt and publishes. `baseUrl` targets Go; `authLinkOrigin` identifies AuthLink. Tenant keys stay in Go. Use your own fetch plus `validateTicket` for post/version fields; SDK create() sends purpose only.
14
+
9
15
  ### Features
10
16
 
11
17
  - Phone-binding QR codes generated locally in the browser.
@@ -81,6 +87,8 @@ The SDK validates the destination before evaluating credentials and refuses redi
81
87
 
82
88
  This complete initialization function uses an example `GET /api/session` endpoint that **your application implements**, returning `{ "csrfToken": "current-session-token" }` for the authenticated session with `Cache-Control: no-store` and CORS enabled. Replace that path with your existing session endpoint.
83
89
 
90
+ `csrfToken` is the JSON property chosen for this generic example. The Go CMS walkthrough uses `{ authenticated: true, csrf: '…' }` instead; map session.csrf to the SDK csrfToken callback without renaming your backend response.
91
+
84
92
  ```ts
85
93
  import { createAuthLinkClient } from '@auth-link/web';
86
94
 
@@ -398,12 +406,271 @@ React and Vue accept `className` for the dialog. Vue also accepts standard strin
398
406
 
399
407
  MIT. See `LICENSE` for the full license text and `THIRD_PARTY_NOTICES.md` for bundled third-party attribution.
400
408
 
409
+ ### Go Vue CMS: en
410
+
411
+ This walkthrough follows a working Go CMS and Vue 3 integration. Example origins replace local deployment details; no test users, passwords or tenant credentials are required by these examples. Your website implements login, persistence and business routes. The web package displays and observes requests; the Go SDK calls AuthLink and validates receipts.
412
+
413
+ #### 1. Install and separate the three origins
414
+
415
+ | Role | Example origin | Configuration |
416
+ | --- | --- | --- |
417
+ | Vue | `https://cms.example.com` | `apiOrigin` is the Go backend below |
418
+ | Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
419
+ | AuthLink API | `https://auth.example.com` | Go SDK `Origin`; browser `authLinkOrigin` |
420
+
421
+ Register the Vue site's origin for the tenant. The Go SDK `Origin` and the API origin in an exported credential JSON identify the AuthLink API. Browser `baseUrl` targets your Go application API. Keep TenantID/APIKey in private backend configuration; public runtime configuration contains origins only.
422
+
423
+ ```sh
424
+ npm install @auth-link/web@latest vue
425
+ # In your Go backend module, after unpacking the supplied Go SDK:
426
+ go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
427
+ go mod edit -require=authlink.local/sdk@v0.0.0
428
+ go mod tidy
429
+ ```
430
+
431
+ The Go SDK is supplied as source or a Go SDK archive from your AuthLink website, not a public Go module or an npm backend. Preserve its own go.mod and import `authlink "authlink.local/sdk"`. Initialize it with trusted `authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey}` through `authlink.New`.
432
+
433
+ #### 2. Log in to the website and load its session
434
+
435
+ Your login route (the reference CMS uses `POST /api/login`) issues a Secure, HttpOnly session cookie. Fetch Go's `GET /api/session` with `credentials:'include'`; require `authenticated:true` and the current `csrf` value before opening requests. Otherwise return to your own login flow.
436
+
437
+ Here the JSON property is **csrf**, while the SDK option is **csrfToken**: use `csrfToken: () => session.value?.csrf ?? ''`. A backend is free to name the response property differently. Go generates and saves the token for that session and validates X-CSRF-Token on creation AND result polling. Never substitute a tenant key or matching number.
438
+
439
+ Two HTTPS subdomains under the same site are cross-origin but same-site. The reference API sets a host-only cookie with `SameSite=Strict; Secure; HttpOnly; Path=/`, without Domain. Requests still target the API host. For a truly cross-site setup, account for browser cookie policy as well as CORS.
440
+
441
+ #### 3. Configure Go CORS and application routes
442
+
443
+ ```http
444
+ Access-Control-Allow-Origin: https://cms.example.com
445
+ Access-Control-Allow-Credentials: true
446
+ Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
447
+ Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
448
+ Vary: Origin
449
+ Cache-Control: no-store
450
+ ```
451
+
452
+ Return these headers only for an exact allowed Vue origin, including allowed-origin error responses. Validate the origin/method/headers of OPTIONS and return 204 before requiring login or CSRF; authenticate and check CSRF/ownership on actual requests. Do not reflect arbitrary origins or use `*`. PUT is for CMS draft editing; narrow it to the routes you implement.
453
+
454
+ | Application route | Input | Output |
455
+ | --- | --- | --- |
456
+ | `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`; logged out: `{authenticated:false}` |
457
+ | `POST /api/authlink/requests` | `{purpose:"bind"}` or `{purpose:"confirm",postId,version}`; `Idempotency-Key` | Public `Ticket` |
458
+ | `POST /api/authlink/requests/{id}/result` | `{}`; current session + CSRF | Pending status/displayCode or verified `confirmed` receipt |
459
+ | `POST /api/posts/{id}/publish` | `{requestId,version}`; current session + CSRF | `{ok:true}` after checking the saved confirmation and intent |
460
+
461
+ Your Go application implements all four routes. postId/version and the publication route are application-specific conventions, not AuthLink fields.
462
+
463
+ #### 4. Persist before creation; commit verification before confirmed
464
+
465
+ ```text
466
+ Vue → Go /api/session → current session + csrf
467
+ Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
468
+ Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
469
+ Go commits verified receipt → Vue confirmed event
470
+ Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
471
+ ```
472
+
473
+ Derive the stable user ID from the authenticated session. Before an upstream call, validate purpose/idempotency and optional post ownership/version; independently generate state and a consume key, then persist CreateInput, the browser creation key, session/user and publication intent. A network retry loads the same record and keys. Enforce unique (session, creation key) and reject reuse for a different purpose/post/version.
474
+
475
+ Save the returned ticket, requestId and original expiresAt; omit optional qrSvg before returning public data. The result route locks the request owned by the current session and reuses any saved receipt. Call Status, then Consume only for approved/consumed using Expected reconstructed from saved data. Persist the verified public receipt, session confirmation and audit transactionally with unique requestId/verificationId constraints; commit before returning confirmed. Pending results preserve displayCode without caching beyond its validity.
476
+
477
+ This is the SDK-call layer, **not a complete HTTP server**. Your application must implement the database/transaction and ownership steps described above and in the comments.
478
+
479
+ ```go
480
+ package cmsauth
481
+
482
+ import (
483
+ "context"
484
+ authlink "authlink.local/sdk"
485
+ )
486
+
487
+ // Load this record from your database for the authenticated user/session.
488
+ // Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
489
+ // Intent (post ID/version), ownership and unique constraints belong in your DB.
490
+ type StoredRequest struct {
491
+ Input authlink.CreateInput
492
+ CreateKey string
493
+ ConsumeKey string
494
+ Ticket authlink.Ticket
495
+ }
496
+
497
+ // On network retries, load the SAME record; do not regenerate State or keys.
498
+ func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
499
+ return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
500
+ }
501
+
502
+ type PublicConfirmation struct {
503
+ Status string `json:"status"`
504
+ RequestID string `json:"requestId"`
505
+ VerificationID string `json:"verificationId"`
506
+ Purpose string `json:"purpose"`
507
+ ExpiresAt string `json:"expiresAt"`
508
+ AuthenticationPolicy string `json:"authenticationPolicy"`
509
+ }
510
+
511
+ // Caller checks session/CSRF/ownership, locks the saved record and first returns
512
+ // any already-saved receipt. For a new confirmed result, caller must persist
513
+ // the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
514
+ func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
515
+ status, err := sdk.Status(ctx, saved.Ticket.RequestID)
516
+ if err != nil { return nil, err }
517
+ if status.Status != "approved" && status.Status != "consumed" { return status, nil }
518
+ result, err := sdk.Consume(ctx, authlink.Expected{
519
+ RequestID: saved.Ticket.RequestID,
520
+ ExternalUserID: saved.Input.ExternalUserID,
521
+ Purpose: saved.Input.Purpose,
522
+ State: saved.Input.State,
523
+ ExpiresAt: saved.Ticket.ExpiresAt,
524
+ }, saved.ConsumeKey)
525
+ if err != nil { return nil, err }
526
+ return PublicConfirmation{
527
+ Status: "confirmed", RequestID: result.RequestID,
528
+ VerificationID: result.VerificationID, Purpose: result.Purpose,
529
+ ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
530
+ }, nil
531
+ }
532
+ ```
533
+
534
+ #### 5. Vue lifecycle and business-bound requests
535
+
536
+ The complete file is also shipped as `examples/vue-cms.vue`. Mount after site login with trusted apiOrigin/authLinkOrigin and an optional saved draft `post={id,version}`. Key the parent component by the current authenticated session identity. Unmount on logout, login rotation or account change; clear old tickets, creation keys and pending publication before mounting a new instance.
537
+
538
+ ```vue
539
+ <script setup lang="ts">
540
+ import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
541
+ import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
542
+ import type { Confirmation, Mode, Ticket } from '@auth-link/web';
543
+ import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
544
+ import '@auth-link/web/styles.css';
545
+
546
+ type Post = { id: string; version: number };
547
+ type Publication = Post & { requestId: string };
548
+ // Supply trusted deployment configuration. Mount after site login and remount
549
+ // with :key="session.subject" after login, logout or account/session changes.
550
+ const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
551
+ const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
552
+ const ticket = shallowRef<Ticket | null>(null);
553
+ const publication = shallowRef<Post | null>(null);
554
+ const retryPublish = shallowRef<Publication | null>(null);
555
+ const busy = ref(false), message = ref('');
556
+ let creation: { signature: string; key: string } | null = null;
557
+ const lifetime = new AbortController();
558
+ const client = markRaw(createAuthLinkClient({
559
+ baseUrl: `${props.apiOrigin}/api/authlink`,
560
+ allowedBackendOrigins: [props.apiOrigin],
561
+ credentials: 'include',
562
+ csrfToken: () => session.value?.csrf ?? '',
563
+ authLinkOrigin: props.authLinkOrigin,
564
+ }));
565
+
566
+ async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
567
+ const response = await fetch(`${props.apiOrigin}${path}`, {
568
+ method: body === undefined ? 'GET' : 'POST',
569
+ credentials: 'include', cache: 'no-store', redirect: 'error',
570
+ signal: lifetime.signal,
571
+ headers: body === undefined ? {} : {
572
+ 'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
573
+ },
574
+ body: body === undefined ? undefined : JSON.stringify(body),
575
+ });
576
+ const value = await response.json();
577
+ if (response.status === 401) {
578
+ session.value = null; closeTicket(); retryPublish.value = null; creation = null;
579
+ }
580
+ if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
581
+ return value;
582
+ }
583
+ function closeTicket() { ticket.value = null; publication.value = null; }
584
+ function showError(error: Error) { message.value = error.message; }
585
+ function finish(text: string) { closeTicket(); message.value = text; }
586
+
587
+ async function start(purpose: Mode, post?: Post) {
588
+ if (busy.value || ticket.value || !session.value?.authenticated) return;
589
+ busy.value = true; message.value = ''; retryPublish.value = null;
590
+ const intent = post ? { id: post.id, version: post.version } : null;
591
+ const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
592
+ try {
593
+ if (creation?.signature !== signature) creation = { signature, key: requestKey() };
594
+ // SDK create() sends only {purpose}. Business fields use your own API.
595
+ const value = intent
596
+ ? validateTicket(await api('/api/authlink/requests', {
597
+ purpose, postId: intent.id, version: intent.version,
598
+ }, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
599
+ : await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
600
+ if (lifetime.signal.aborted) return;
601
+ publication.value = intent; ticket.value = value; creation = null;
602
+ } catch (error) {
603
+ if (!lifetime.signal.aborted) showError(error as Error);
604
+ // Retain creation.key for a retry of the SAME purpose/post/version.
605
+ } finally { busy.value = false; }
606
+ }
607
+ async function publish() {
608
+ const intent = retryPublish.value;
609
+ if (!intent || busy.value) return;
610
+ busy.value = true;
611
+ try {
612
+ await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
613
+ retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
614
+ } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
615
+ finally { busy.value = false; }
616
+ }
617
+ async function confirmed(result: Confirmation) {
618
+ const intent = publication.value;
619
+ closeTicket();
620
+ if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
621
+ else message.value = 'Verified. Reload account status from the backend.';
622
+ }
623
+ onMounted(async () => {
624
+ try {
625
+ const value = await api('/api/session');
626
+ if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
627
+ throw new Error('LOGIN_REQUIRED');
628
+ }
629
+ session.value = value;
630
+ } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
631
+ });
632
+ onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
633
+ </script>
634
+
635
+ <template>
636
+ <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
637
+ <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
638
+ <button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
639
+ <button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
640
+ <AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
641
+ @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
642
+ @expired="finish('Expired; start a new request')" @error="showError" />
643
+ <AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
644
+ @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
645
+ @expired="finish('Expired; start a new request')" @error="showError" />
646
+ <p role="status">{{ message }}</p>
647
+ </template>
648
+ ```
649
+
650
+ `client.create(purpose, {idempotencyKey})` sends purpose only. To bind confirmation to a post/version, use your own fetch with those business fields, validate the result with `validateTicket(response, 'confirm', authLinkOrigin)`, and reuse the official client for observation. Keep the same creation key after an uncertain failure, and change it when purpose/post/version changes. Keep tickets in shallowRef and clients in markRaw.
651
+
652
+ Mounting a dialog observes an existing request; never create from mounted/watch. Clear the ticket on close/rejection/expiry. Leave the dialog mounted on network errors to retry that request. On confirmation, dismiss it and call the independent publish route; preserve the same requestId/post/version if that call fails. Reload actual account/post state from the backend. The sample omits your login form, post list and application error translations.
653
+
654
+ #### 6. Recheck confirmation in the publish transaction
655
+
656
+ The browser confirmed event is not authorization. Go must load the verified receipt for the current session and check purpose=confirm, intent=publish, post ownership, the same postId/version, draft state and expiry on first application. Update the post and mark the intent applied atomically. A retry of that same applied operation returns success without duplicate publication/audit. Another post or edited version requires a new confirmation. Do not add action, transactionHash or document content to the AuthLink receipt.
657
+
658
+ #### Acceptance sequence
659
+
660
+ Site login → session bootstrap → backend AccountStatus (the reference CMS exposes it through overview) → bind if unbound → phone approval → consume and refresh account status → save draft → confirm that post/version → phone approval → commit receipt → publish → verify duplicate retry, wrong version, rejection and expiry. Real phone approval remains a separate acceptance step; compilation and mock HTTP checks do not replace it.
661
+
401
662
  ---
402
663
 
403
664
  ## 简体中文
404
665
 
405
666
  **AuthLink 官方前端组件包**,帮助网站接入手机绑定与 App 确认流程,支持 React 18/19、Vue 3.3+、TypeScript 和 ESM。
406
667
 
668
+ ### Go + Vue 完整接入
669
+
670
+ 按实际 CMS 流程接入,推荐先读 [Go + Vue:从登录、绑定到确认发布](#go-vue-cms-zh-cn),完整组件见 `examples/vue-cms.vue`。
671
+
672
+ 顺序是:网站登录 → Go `/api/session` 返回 `authenticated` 与 `csrf` → Vue 通过 `csrfToken: () => session.csrf` 配置跨域客户端 → 创建 bind/confirm → Go 验证并消费结果后落库 → Vue 清理弹窗 → 独立业务接口再校验已保存回执并发布。`baseUrl` 是 Go 业务 API,`authLinkOrigin` 是 AuthLink API;租户密钥始终留在 Go 后台。带文章 ID/版本的确认通过网站自己的 fetch 创建并用 `validateTicket` 校验,SDK 的 `create` 只发送 purpose。
673
+
407
674
 
408
675
  ### 功能
409
676
 
@@ -494,6 +761,8 @@ export const client = createAuthLinkClient({
494
761
 
495
762
  下面给出完整的初始化函数。`GET /api/session` 是接入网站自己实现的示例会话接口,不是包或 AuthLink 服务自带的接口;它应根据现有登录 cookie 返回 `{ "csrfToken": "当前会话的真实CSRF token" }`,设置 `Cache-Control: no-store` 并配置下文的 CORS。部署时将路径换成网站实际的会话初始化接口。
496
763
 
764
+ 这里的 `csrfToken` 是通用示例选定的 JSON 字段名。Go CMS 实例实际返回 `{ authenticated: true, csrf: '…' }`;读取 `session.csrf` 并交给同名 SDK 配置回调即可,无须改动后台响应。完整映射见上方 Go + Vue 指南。
765
+
497
766
  ```ts
498
767
  import { createAuthLinkClient } from '@auth-link/web';
499
768
 
@@ -866,12 +1135,275 @@ React 18.2+ / 19、Vue 3.3+、TypeScript、ESM。React 和 Vue 是可选 peer,
866
1135
 
867
1136
  MIT。完整许可文本见 `LICENSE`,打包包含的第三方库归属说明见 `THIRD_PARTY_NOTICES.md`。
868
1137
 
1138
+ ### Go Vue CMS: zh-CN
1139
+
1140
+ 本指南按实际 Go CMS + Vue 3 的接入顺序整理。下面使用可替换的示例域名,不依赖某台电脑的配置、测试账号、密码或租户密钥。网站负责登录、数据库和业务接口;Web SDK 负责展示和观察请求,Go SDK 负责后台调用与回执校验。
1141
+
1142
+ #### 1. 区分三个来源并安装 SDK
1143
+
1144
+ | 角色 | 示例来源 | 配置位置 |
1145
+ | --- | --- | --- |
1146
+ | Vue | `https://cms.example.com` | `apiOrigin` 指向下方的 Go 后台 |
1147
+ | Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
1148
+ | AuthLink API | `https://auth.example.com` | Go SDK 的 `Origin`;浏览器的 `authLinkOrigin` |
1149
+
1150
+ 登记租户的站点 origin 是 Vue 网站来源;Go SDK 的 `Origin` 和凭据导出 JSON 中的 API origin 是 AuthLink API 来源。浏览器的 `baseUrl` 指向 Go 业务后台,不能指向 AuthLink 租户 API。公开运行配置只包含这三个 origin;`TenantID`、`APIKey` 保留在 Go 私密配置或环境变量中。
1151
+
1152
+ ```sh
1153
+ npm install @auth-link/web@latest vue
1154
+ # In your Go backend module, after unpacking the supplied Go SDK:
1155
+ go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
1156
+ go mod edit -require=authlink.local/sdk@v0.0.0
1157
+ go mod tidy
1158
+ ```
1159
+
1160
+ Go SDK 当前随源码或官网的 Go SDK 源码包交付,保留 `third_party/authlink-go/go.mod`,导入 `authlink "authlink.local/sdk"`;这不是公共 `go get` 地址,也不是 npm 包内置的后台。用可信后台配置调用 `authlink.New(authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey})`。
1161
+
1162
+ #### 2. 先完成网站登录,再读取会话
1163
+
1164
+ 本站现有登录接口(参考 CMS 为 `POST /api/login`)验证用户后签发 HttpOnly、Secure 的 session Cookie。Vue 用 `credentials:'include'` 调用 Go 的 `GET /api/session`。已登录时返回 `authenticated:true` 与 `csrf`;未登录时先回到网站登录流程。
1165
+
1166
+ `csrf` 是本 CMS 的响应字段名;SDK 配置项仍叫 `csrfToken`,写成 `csrfToken: () => session.value?.csrf ?? ''` 即可。SDK 不要求你的后台一定返回名为 `csrfToken` 的字段。令牌由 Go 为当前 session 生成并保存,浏览器只读取,不能用租户密钥或手机核对码代替。创建和结果查询均需验证 `X-CSRF-Token` 与同一 session 的值一致。
1167
+
1168
+ Vue 与 API 使用同一 HTTPS 主域名下的两个子域名时,是跨 origin、同 site。参考 CMS 的 host-only Cookie 不设置 `Domain`,使用 `SameSite=Strict; Secure; HttpOnly; Path=/`;请求目标仍为 API 域名。真正跨 site 时还需按浏览器 Cookie 策略配置,不能只依靠 CORS。
1169
+
1170
+ #### 3. Go 配置 CORS 与业务路由
1171
+
1172
+ ```http
1173
+ Access-Control-Allow-Origin: https://cms.example.com
1174
+ Access-Control-Allow-Credentials: true
1175
+ Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
1176
+ Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
1177
+ Vary: Origin
1178
+ Cache-Control: no-store
1179
+ ```
1180
+
1181
+ 只为精确允许的 Vue origin 返回这些 CORS 头;允许来源的正常和错误响应都要携带。`OPTIONS` 先验证来源、方法和请求头,成功返回 204,不要求登录或 CSRF;随后实际请求才验证登录、来源、CSRF 和归属。不能用 `*` 或无条件反射来源。上面包含 CMS 草稿编辑所用的 PUT;只接入 SDK 的站点可按实际路由收窄。
1182
+
1183
+ | 网站自己的路由 | 请求 | 返回 |
1184
+ | --- | --- | --- |
1185
+ | `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`;未登录: `{authenticated:false}` |
1186
+ | `POST /api/authlink/requests` | `{purpose:"bind"}` 或 `{purpose:"confirm",postId,version}`; `Idempotency-Key` | 公开 `Ticket` |
1187
+ | `POST /api/authlink/requests/{id}/result` | `{}`;当前 session + CSRF | 待处理状态/displayCode 或已验证的 `confirmed` 回执 |
1188
+ | `POST /api/posts/{id}/publish` | `{requestId,version}`; 当前 session + CSRF | `{ok:true}` 校验已保存的确认记录和业务意图后返回 |
1189
+
1190
+ 上述路由由 Go CMS 实现,不是安装 Web SDK 后自动出现的接口。`postId` / `version` / `/publish` 是本例业务约定,不是 AuthLink 平台字段。
1191
+
1192
+ #### 4. 创建前落库,消费后再返回 confirmed
1193
+
1194
+ ```text
1195
+ Vue → Go /api/session → current session + csrf
1196
+ Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
1197
+ Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
1198
+ Go commits verified receipt → Vue confirmed event
1199
+ Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
1200
+ ```
1201
+
1202
+ Go 从可信 session 取稳定用户 ID,不能接受浏览器自报的 `externalUserId`。创建顺序:
1203
+
1204
+ 1. 校验 purpose、创建幂等键,以及文章归属和当前版本(若有)。
1205
+ 2. 生成独立随机 state 和消费幂等键;把原创建键、CreateInput、当前用户/session 和业务意图落库,再向 AuthLink 发请求。网络结果不明时从数据库恢复同一记录、输入和键。
1206
+ 3. 调用 `CreateRequest`,保存返回的 requestId、原始 expiresAt 与 ticket,去掉可选 qrSvg 后返回公开 ticket。对同一 session + 创建键加唯一约束,键复用到其他 purpose / 文章 / 版本时返回冲突。
1207
+ 4. 结果接口按 requestId + 当前 session 加锁读取记录;已保存回执直接复用。用 `Status` 查询,只有 approved / consumed 才以保存的 `Expected` 和消费键调用 `Consume`。SDK 校验失败不能返回成功。
1208
+ 5. 在事务里保存回执、用户/session 确认记录和审计,requestId / verificationId 建唯一约束,提交成功后才返回 `confirmed`。待处理状态保留原 displayCode,不跨数字期限缓存。
1209
+
1210
+ 下面是 SDK 调用层代码,**不是包含登录、数据库和路由的完整 HTTP 服务**;注释中的落库与事务必须由你的 Go 后台实现。初始化客户端、构造 Input、所有权检查及请求记录的读写按上述步骤完成。
1211
+
1212
+ ```go
1213
+ package cmsauth
1214
+
1215
+ import (
1216
+ "context"
1217
+ authlink "authlink.local/sdk"
1218
+ )
1219
+
1220
+ // Load this record from your database for the authenticated user/session.
1221
+ // Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
1222
+ // Intent (post ID/version), ownership and unique constraints belong in your DB.
1223
+ type StoredRequest struct {
1224
+ Input authlink.CreateInput
1225
+ CreateKey string
1226
+ ConsumeKey string
1227
+ Ticket authlink.Ticket
1228
+ }
1229
+
1230
+ // On network retries, load the SAME record; do not regenerate State or keys.
1231
+ func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
1232
+ return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
1233
+ }
1234
+
1235
+ type PublicConfirmation struct {
1236
+ Status string `json:"status"`
1237
+ RequestID string `json:"requestId"`
1238
+ VerificationID string `json:"verificationId"`
1239
+ Purpose string `json:"purpose"`
1240
+ ExpiresAt string `json:"expiresAt"`
1241
+ AuthenticationPolicy string `json:"authenticationPolicy"`
1242
+ }
1243
+
1244
+ // Caller checks session/CSRF/ownership, locks the saved record and first returns
1245
+ // any already-saved receipt. For a new confirmed result, caller must persist
1246
+ // the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
1247
+ func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
1248
+ status, err := sdk.Status(ctx, saved.Ticket.RequestID)
1249
+ if err != nil { return nil, err }
1250
+ if status.Status != "approved" && status.Status != "consumed" { return status, nil }
1251
+ result, err := sdk.Consume(ctx, authlink.Expected{
1252
+ RequestID: saved.Ticket.RequestID,
1253
+ ExternalUserID: saved.Input.ExternalUserID,
1254
+ Purpose: saved.Input.Purpose,
1255
+ State: saved.Input.State,
1256
+ ExpiresAt: saved.Ticket.ExpiresAt,
1257
+ }, saved.ConsumeKey)
1258
+ if err != nil { return nil, err }
1259
+ return PublicConfirmation{
1260
+ Status: "confirmed", RequestID: result.RequestID,
1261
+ VerificationID: result.VerificationID, Purpose: result.Purpose,
1262
+ ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
1263
+ }, nil
1264
+ }
1265
+ ```
1266
+
1267
+ #### 5. Vue 组件:稳定 ticket、按业务绑定创建、清理与重试
1268
+
1269
+ 完整文件也包含在包内 `examples/vue-cms.vue`。网站登录后挂载它,传入可信 `apiOrigin`、`authLinkOrigin`,可选传入后台加载的草稿 `post={id,version}`。父层用当前登录 session 的标识做组件 key,退出、重新登录或切换账号时先卸载旧组件并清空会话、旧 ticket、幂等键和待发布操作,再挂载新组件。不要只改用户名后继续使用旧 ticket。
1270
+
1271
+ ```vue
1272
+ <script setup lang="ts">
1273
+ import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
1274
+ import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
1275
+ import type { Confirmation, Mode, Ticket } from '@auth-link/web';
1276
+ import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
1277
+ import '@auth-link/web/styles.css';
1278
+
1279
+ type Post = { id: string; version: number };
1280
+ type Publication = Post & { requestId: string };
1281
+ // Supply trusted deployment configuration. Mount after site login and remount
1282
+ // with :key="session.subject" after login, logout or account/session changes.
1283
+ const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
1284
+ const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
1285
+ const ticket = shallowRef<Ticket | null>(null);
1286
+ const publication = shallowRef<Post | null>(null);
1287
+ const retryPublish = shallowRef<Publication | null>(null);
1288
+ const busy = ref(false), message = ref('');
1289
+ let creation: { signature: string; key: string } | null = null;
1290
+ const lifetime = new AbortController();
1291
+ const client = markRaw(createAuthLinkClient({
1292
+ baseUrl: `${props.apiOrigin}/api/authlink`,
1293
+ allowedBackendOrigins: [props.apiOrigin],
1294
+ credentials: 'include',
1295
+ csrfToken: () => session.value?.csrf ?? '',
1296
+ authLinkOrigin: props.authLinkOrigin,
1297
+ }));
1298
+
1299
+ async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
1300
+ const response = await fetch(`${props.apiOrigin}${path}`, {
1301
+ method: body === undefined ? 'GET' : 'POST',
1302
+ credentials: 'include', cache: 'no-store', redirect: 'error',
1303
+ signal: lifetime.signal,
1304
+ headers: body === undefined ? {} : {
1305
+ 'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
1306
+ },
1307
+ body: body === undefined ? undefined : JSON.stringify(body),
1308
+ });
1309
+ const value = await response.json();
1310
+ if (response.status === 401) {
1311
+ session.value = null; closeTicket(); retryPublish.value = null; creation = null;
1312
+ }
1313
+ if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
1314
+ return value;
1315
+ }
1316
+ function closeTicket() { ticket.value = null; publication.value = null; }
1317
+ function showError(error: Error) { message.value = error.message; }
1318
+ function finish(text: string) { closeTicket(); message.value = text; }
1319
+
1320
+ async function start(purpose: Mode, post?: Post) {
1321
+ if (busy.value || ticket.value || !session.value?.authenticated) return;
1322
+ busy.value = true; message.value = ''; retryPublish.value = null;
1323
+ const intent = post ? { id: post.id, version: post.version } : null;
1324
+ const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
1325
+ try {
1326
+ if (creation?.signature !== signature) creation = { signature, key: requestKey() };
1327
+ // SDK create() sends only {purpose}. Business fields use your own API.
1328
+ const value = intent
1329
+ ? validateTicket(await api('/api/authlink/requests', {
1330
+ purpose, postId: intent.id, version: intent.version,
1331
+ }, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
1332
+ : await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
1333
+ if (lifetime.signal.aborted) return;
1334
+ publication.value = intent; ticket.value = value; creation = null;
1335
+ } catch (error) {
1336
+ if (!lifetime.signal.aborted) showError(error as Error);
1337
+ // Retain creation.key for a retry of the SAME purpose/post/version.
1338
+ } finally { busy.value = false; }
1339
+ }
1340
+ async function publish() {
1341
+ const intent = retryPublish.value;
1342
+ if (!intent || busy.value) return;
1343
+ busy.value = true;
1344
+ try {
1345
+ await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
1346
+ retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
1347
+ } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
1348
+ finally { busy.value = false; }
1349
+ }
1350
+ async function confirmed(result: Confirmation) {
1351
+ const intent = publication.value;
1352
+ closeTicket();
1353
+ if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
1354
+ else message.value = 'Verified. Reload account status from the backend.';
1355
+ }
1356
+ onMounted(async () => {
1357
+ try {
1358
+ const value = await api('/api/session');
1359
+ if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
1360
+ throw new Error('LOGIN_REQUIRED');
1361
+ }
1362
+ session.value = value;
1363
+ } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
1364
+ });
1365
+ onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
1366
+ </script>
1367
+
1368
+ <template>
1369
+ <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
1370
+ <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
1371
+ <button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
1372
+ <button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
1373
+ <AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
1374
+ @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
1375
+ @expired="finish('Expired; start a new request')" @error="showError" />
1376
+ <AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
1377
+ @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
1378
+ @expired="finish('Expired; start a new request')" @error="showError" />
1379
+ <p role="status">{{ message }}</p>
1380
+ </template>
1381
+ ```
1382
+
1383
+ 普通绑定/确认使用 `client.create(purpose, {idempotencyKey})`。该方法只发送 purpose,**不能传入 postId/version 扩展请求体**。确认发布通过自己的 fetch 发送这些业务字段,再用 `validateTicket(response, 'confirm', authLinkOrigin)` 校验;后续弹窗查询仍复用官方 client。
1384
+
1385
+ 同一次创建失败保留同一个键;用途或文章版本改变才换新键。`shallowRef` 保存 ticket,`markRaw` 保存客户端。组件挂载只打开和观察已有请求;不在 mounted/watch 中创建请求。关闭、拒绝和过期清空 ticket;网络错误保留弹窗,允许重试原请求。确认成功先清空 ticket,再调用独立业务接口;失败保留同一个 requestId / 文章 / 版本用于重试,先从后台确认真实状态后再刷新页面。上例省略网站自己的登录表单、文章列表和错误码中文映射。
1386
+
1387
+ #### 6. 发布接口必须再做后台检查
1388
+
1389
+ `confirmed` 回调只触发界面更新与下一次 API 请求,不是放行凭据。Go 的发布事务必须验证当前 session 对应的已落库回执,purpose=confirm、意图=publish、同一 postId、同一 version、文章归属和草稿状态,以及首次应用时尚未过期。成功时更新文章并标记请求 applied;重试已应用的同一操作返回原成功,不重复发布或写审计。请求不能用于另一文章或新版本,修改内容后须重新确认。前端不把文章内容、action 或 transactionHash 塞入 AuthLink 消费回执。
1390
+
1391
+ #### 验收顺序
1392
+
1393
+ 登录 → 读取 session → 查询后台真实账号状态(参考 CMS 通过 overview 调用 Go SDK `AccountStatus`)→ 未绑定时 bind → 手机扫码批准 → 后台消费并刷新账号状态 → 保存草稿 → 按 postId/version confirm → 手机批准 → 结果消费落库 → 独立发布接口 → 重复发布/错误版本/拒绝/过期验证。真实设备验收仍由用户在手机上完成;本地编译和模拟 HTTP 测试不能代替这一步。
1394
+
869
1395
  ---
870
1396
 
871
1397
  ## 日本語
872
1398
 
873
1399
  **AuthLink のスマートフォン登録とアプリでの確認**を Web サイトに組み込む React / Vue コンポーネントです。登録用 QR コード、同期する 6 桁の照合コード、カウントダウン、確認結果を表示します。特定のサイト基盤には依存しません。
874
1400
 
1401
+ ### Go + Vue の接続手順
1402
+
1403
+ Go + Vue の実際の接続手順は [ログイン、端末登録、確認後の公開](#go-vue-cms-ja)、完全なコンポーネントは `examples/vue-cms.vue` を参照してください。
1404
+
1405
+ サイトのログイン → Go `/api/session` の authenticated/csrf → `csrfToken: () => session.csrf` で別 origin のクライアント設定 → bind/confirm 作成 → Go で消費・検証・DB コミット → ダイアログを閉じる → 独立した業務 API で保存済み確認結果を再検証して公開、という順序です。baseUrl は Go、authLinkOrigin は AuthLink を指し、テナントキーは Go に保持します。記事/版付き要求は独自 fetch と validateTicket を使い、SDK create は purpose のみ送ります。
1406
+
875
1407
  ### 主な機能
876
1408
 
877
1409
  - ブラウザー内で生成するスマートフォン登録用 QR コード。
@@ -945,6 +1477,8 @@ SDK は認証情報を読む前に接続先を検証し、リダイレクトを
945
1477
 
946
1478
  #### 別オリジンの Cookie / session
947
1479
 
1480
+ 汎用例の JSON 名は csrfToken ですが、Go CMS の実際の応答は `{ authenticated: true, csrf: '…' }` です。session.csrf を SDK の csrfToken コールバックに渡せばよく、バックエンドのフィールド名を変更する必要はありません。
1481
+
948
1482
  下記の `GET /api/session` はサイトで実装する例示 API で、SDK が提供する API ではありません。ログイン session の `{ "csrfToken": "現在のtoken" }` を返し、`Cache-Control: no-store` と CORS を設定します。既存サイトの API パスに適合してください。
949
1483
 
950
1484
  ```ts
@@ -1263,3 +1797,256 @@ React / Vue はダイアログの `className` に対応します。Vue は文字
1263
1797
  ### ライセンス
1264
1798
 
1265
1799
  MIT。ライセンス全文は `LICENSE`、同梱ライブラリの帰属表示は `THIRD_PARTY_NOTICES.md` を参照してください。
1800
+
1801
+ ### Go Vue CMS: ja
1802
+
1803
+ 実際の Go CMS + Vue 3 の接続順序を示します。例のドメインは置き換え用で、ローカル設定・テスト用パスワード・テナント資格情報は含めません。ログイン、DB、業務 API はサイト側で実装し、Web SDK は表示と監視、Go SDK は AuthLink 呼び出しと応答検証を担当します。
1804
+
1805
+ #### 1. 3 つの origin と SDK
1806
+
1807
+ | 役割 | 例 | 設定 |
1808
+ | --- | --- | --- |
1809
+ | Vue | `https://cms.example.com` | `apiOrigin` は下記 Go バックエンド |
1810
+ | Go CMS API | `https://cms-api.example.com` | `baseUrl=https://cms-api.example.com/api/authlink` |
1811
+ | AuthLink API | `https://auth.example.com` | Go SDK の `Origin`;ブラウザーの `authLinkOrigin` |
1812
+
1813
+ テナントに登録するサイト origin は Vue の origin です。Go SDK の Origin と資格情報 JSON の API origin は AuthLink API を指します。ブラウザーの baseUrl は Go の業務 API です。TenantID/APIKey はバックエンドの非公開設定にのみ保存します。
1814
+
1815
+ ```sh
1816
+ npm install @auth-link/web@latest vue
1817
+ # In your Go backend module, after unpacking the supplied Go SDK:
1818
+ go mod edit -replace=authlink.local/sdk=./third_party/authlink-go
1819
+ go mod edit -require=authlink.local/sdk@v0.0.0
1820
+ go mod tidy
1821
+ ```
1822
+
1823
+ Go SDK はソースまたは公式サイトの Go SDK アーカイブとして取得します。公開 Go module や npm 同梱サーバーではありません。SDK 自身の go.mod を保持し、`authlink "authlink.local/sdk"` を import します。信頼できる設定から `authlink.New(authlink.Config{Origin: authOrigin, TenantID: tenantID, APIKey: apiKey})` で初期化します。
1824
+
1825
+ #### 2. サイトのログインと session
1826
+
1827
+ サイトのログイン API(参照 CMS は POST /api/login)が Secure・HttpOnly の session Cookie を発行します。Vue は credentials=include で GET /api/session を読み、authenticated=true と csrf を確認してから要求を作成します。未ログインならサイト自身のログインへ戻します。
1828
+
1829
+ この CMS の JSON フィールド名は **csrf**、SDK オプション名は **csrfToken** です。`csrfToken: () => session.value?.csrf ?? ''` で対応します。JSON の名前を SDK に合わせる必要はありません。Go が session ごとに生成・保存し、作成と結果照会の両方で X-CSRF-Token を検証します。テナントキーや照合数字を代用しません。
1830
+
1831
+ 同じサイトの HTTPS サブドメイン同士は別 origin・同 site です。参照 API の Cookie は Domain を指定しない host-only、SameSite=Strict; Secure; HttpOnly; Path=/ です。別 site の場合は CORS に加えてブラウザーの Cookie 制限も考慮します。
1832
+
1833
+ #### 3. Go の CORS と業務ルート
1834
+
1835
+ ```http
1836
+ Access-Control-Allow-Origin: https://cms.example.com
1837
+ Access-Control-Allow-Credentials: true
1838
+ Access-Control-Allow-Methods: GET, POST, PUT, OPTIONS
1839
+ Access-Control-Allow-Headers: Content-Type, X-CSRF-Token, Idempotency-Key
1840
+ Vary: Origin
1841
+ Cache-Control: no-store
1842
+ ```
1843
+
1844
+ 正確に許可した Vue origin にだけ返し、その origin のエラー応答にも付与します。OPTIONS は origin・method・headers を検証して 204 を返し、ログインや CSRF は要求しません。実リクエストでログイン、CSRF、所有者を検証します。任意 origin の反射や * は使いません。PUT は CMS の下書き編集用なので必要なルートに合わせて限定できます。
1845
+
1846
+ | サイト自身のルート | 入力 | 出力 |
1847
+ | --- | --- | --- |
1848
+ | `GET /api/session` | — | `{authenticated:true, csrf:"…", subject:"…", username:"…"}`;未ログイン: `{authenticated:false}` |
1849
+ | `POST /api/authlink/requests` | `{purpose:"bind"}` または `{purpose:"confirm",postId,version}`; `Idempotency-Key` | 公開 `Ticket` |
1850
+ | `POST /api/authlink/requests/{id}/result` | `{}`;現在の session + CSRF | 待機状態/displayCode または検証済み `confirmed` |
1851
+ | `POST /api/posts/{id}/publish` | `{requestId,version}`; 現在の session + CSRF | `{ok:true}` 保存済み確認結果と業務意図の検証後 |
1852
+
1853
+ これらのルートは Go CMS が実装します。postId/version と公開ルートはこの業務の契約で、AuthLink のプロトコル項目ではありません。
1854
+
1855
+ #### 4. 作成前に保存し、検証のコミット後に confirmed
1856
+
1857
+ ```text
1858
+ Vue → Go /api/session → current session + csrf
1859
+ Vue → Go /api/authlink/requests → Go SDK CreateRequest → public ticket
1860
+ Vue dialog → Go /api/authlink/requests/{id}/result → Status + Consume
1861
+ Go commits verified receipt → Vue confirmed event
1862
+ Vue → Go /api/posts/{id}/publish → validate saved intent/receipt → publish
1863
+ ```
1864
+
1865
+ 安定したユーザー ID は認証済み session から取得します。用途、作成キー、記事の所有者と版を検証し、state と消費キーを別々に生成します。上流通信の前に CreateInput、ブラウザーの作成キー、ユーザー/session、記事公開の意図を保存します。通信失敗時は同一レコード・入力・キーを再利用します。session + 作成キーを一意にし、異なる用途・記事・版への再利用を拒否します。
1866
+
1867
+ ticket、requestId、元の expiresAt を保存し、任意の qrSvg を除いて公開 ticket を返します。結果 API は現在の session が所有する記録をロックし、保存済み確認結果があれば再利用します。Status が approved/consumed のときだけ、DB の値から Expected を組み立て Consume を実行します。検証済み確認結果、session の確認記録、監査を requestId/verificationId の一意制約付きトランザクションで保存し、コミット後に confirmed を返します。待機中は displayCode を有効期限を越えてキャッシュしません。
1868
+
1869
+ 以下は SDK 呼び出し層であり、完全な HTTP サーバーではありません。DB、トランザクション、所有者検証は説明とコメントに従ってサイト側で実装します。
1870
+
1871
+ ```go
1872
+ package cmsauth
1873
+
1874
+ import (
1875
+ "context"
1876
+ authlink "authlink.local/sdk"
1877
+ )
1878
+
1879
+ // Load this record from your database for the authenticated user/session.
1880
+ // Save Input (including State), CreateKey and ConsumeKey BEFORE calling Create.
1881
+ // Intent (post ID/version), ownership and unique constraints belong in your DB.
1882
+ type StoredRequest struct {
1883
+ Input authlink.CreateInput
1884
+ CreateKey string
1885
+ ConsumeKey string
1886
+ Ticket authlink.Ticket
1887
+ }
1888
+
1889
+ // On network retries, load the SAME record; do not regenerate State or keys.
1890
+ func Create(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (authlink.Ticket, error) {
1891
+ return sdk.CreateRequest(ctx, saved.Input, saved.CreateKey)
1892
+ }
1893
+
1894
+ type PublicConfirmation struct {
1895
+ Status string `json:"status"`
1896
+ RequestID string `json:"requestId"`
1897
+ VerificationID string `json:"verificationId"`
1898
+ Purpose string `json:"purpose"`
1899
+ ExpiresAt string `json:"expiresAt"`
1900
+ AuthenticationPolicy string `json:"authenticationPolicy"`
1901
+ }
1902
+
1903
+ // Caller checks session/CSRF/ownership, locks the saved record and first returns
1904
+ // any already-saved receipt. For a new confirmed result, caller must persist
1905
+ // the receipt and confirmation in a DB transaction BEFORE returning HTTP 200.
1906
+ func Poll(ctx context.Context, sdk *authlink.Client, saved StoredRequest) (any, error) {
1907
+ status, err := sdk.Status(ctx, saved.Ticket.RequestID)
1908
+ if err != nil { return nil, err }
1909
+ if status.Status != "approved" && status.Status != "consumed" { return status, nil }
1910
+ result, err := sdk.Consume(ctx, authlink.Expected{
1911
+ RequestID: saved.Ticket.RequestID,
1912
+ ExternalUserID: saved.Input.ExternalUserID,
1913
+ Purpose: saved.Input.Purpose,
1914
+ State: saved.Input.State,
1915
+ ExpiresAt: saved.Ticket.ExpiresAt,
1916
+ }, saved.ConsumeKey)
1917
+ if err != nil { return nil, err }
1918
+ return PublicConfirmation{
1919
+ Status: "confirmed", RequestID: result.RequestID,
1920
+ VerificationID: result.VerificationID, Purpose: result.Purpose,
1921
+ ExpiresAt: saved.Ticket.ExpiresAt, AuthenticationPolicy: result.AuthenticationPolicy,
1922
+ }, nil
1923
+ }
1924
+ ```
1925
+
1926
+ #### 5. Vue のライフサイクルと業務データ付き要求
1927
+
1928
+ 完全なファイルは `examples/vue-cms.vue` にも同梱します。サイトのログイン後、信頼できる apiOrigin/authLinkOrigin と任意の保存済み下書き `post={id,version}` を渡してマウントします。親は認証 session の識別子を key に使います。ログアウト・再ログイン・アカウント切り替えでは先にアンマウントし、古い ticket・作成キー・未完了の公開処理を消去してから新規マウントします。
1929
+
1930
+ ```vue
1931
+ <script setup lang="ts">
1932
+ import { markRaw, onMounted, onUnmounted, ref, shallowRef } from 'vue';
1933
+ import { createAuthLinkClient, requestKey, validateTicket } from '@auth-link/web';
1934
+ import type { Confirmation, Mode, Ticket } from '@auth-link/web';
1935
+ import { AuthLinkBind, AuthLinkConfirm } from '@auth-link/web/vue';
1936
+ import '@auth-link/web/styles.css';
1937
+
1938
+ type Post = { id: string; version: number };
1939
+ type Publication = Post & { requestId: string };
1940
+ // Supply trusted deployment configuration. Mount after site login and remount
1941
+ // with :key="session.subject" after login, logout or account/session changes.
1942
+ const props = defineProps<{ apiOrigin: string; authLinkOrigin: string; post?: Post }>();
1943
+ const session = ref<{ authenticated: boolean; csrf?: string } | null>(null);
1944
+ const ticket = shallowRef<Ticket | null>(null);
1945
+ const publication = shallowRef<Post | null>(null);
1946
+ const retryPublish = shallowRef<Publication | null>(null);
1947
+ const busy = ref(false), message = ref('');
1948
+ let creation: { signature: string; key: string } | null = null;
1949
+ const lifetime = new AbortController();
1950
+ const client = markRaw(createAuthLinkClient({
1951
+ baseUrl: `${props.apiOrigin}/api/authlink`,
1952
+ allowedBackendOrigins: [props.apiOrigin],
1953
+ credentials: 'include',
1954
+ csrfToken: () => session.value?.csrf ?? '',
1955
+ authLinkOrigin: props.authLinkOrigin,
1956
+ }));
1957
+
1958
+ async function api(path: string, body?: unknown, headers: Record<string, string> = {}) {
1959
+ const response = await fetch(`${props.apiOrigin}${path}`, {
1960
+ method: body === undefined ? 'GET' : 'POST',
1961
+ credentials: 'include', cache: 'no-store', redirect: 'error',
1962
+ signal: lifetime.signal,
1963
+ headers: body === undefined ? {} : {
1964
+ 'Content-Type': 'application/json', 'X-CSRF-Token': session.value?.csrf ?? '', ...headers,
1965
+ },
1966
+ body: body === undefined ? undefined : JSON.stringify(body),
1967
+ });
1968
+ const value = await response.json();
1969
+ if (response.status === 401) {
1970
+ session.value = null; closeTicket(); retryPublish.value = null; creation = null;
1971
+ }
1972
+ if (!response.ok) throw new Error(value.code ?? 'REQUEST_FAILED');
1973
+ return value;
1974
+ }
1975
+ function closeTicket() { ticket.value = null; publication.value = null; }
1976
+ function showError(error: Error) { message.value = error.message; }
1977
+ function finish(text: string) { closeTicket(); message.value = text; }
1978
+
1979
+ async function start(purpose: Mode, post?: Post) {
1980
+ if (busy.value || ticket.value || !session.value?.authenticated) return;
1981
+ busy.value = true; message.value = ''; retryPublish.value = null;
1982
+ const intent = post ? { id: post.id, version: post.version } : null;
1983
+ const signature = JSON.stringify({ purpose, postId: intent?.id, version: intent?.version });
1984
+ try {
1985
+ if (creation?.signature !== signature) creation = { signature, key: requestKey() };
1986
+ // SDK create() sends only {purpose}. Business fields use your own API.
1987
+ const value = intent
1988
+ ? validateTicket(await api('/api/authlink/requests', {
1989
+ purpose, postId: intent.id, version: intent.version,
1990
+ }, { 'Idempotency-Key': creation.key }), purpose, props.authLinkOrigin)
1991
+ : await client.create(purpose, { idempotencyKey: creation.key, signal: lifetime.signal });
1992
+ if (lifetime.signal.aborted) return;
1993
+ publication.value = intent; ticket.value = value; creation = null;
1994
+ } catch (error) {
1995
+ if (!lifetime.signal.aborted) showError(error as Error);
1996
+ // Retain creation.key for a retry of the SAME purpose/post/version.
1997
+ } finally { busy.value = false; }
1998
+ }
1999
+ async function publish() {
2000
+ const intent = retryPublish.value;
2001
+ if (!intent || busy.value) return;
2002
+ busy.value = true;
2003
+ try {
2004
+ await api(`/api/posts/${intent.id}/publish`, { version: intent.version, requestId: intent.requestId });
2005
+ retryPublish.value = null; message.value = 'Published. Reload your posts from the backend.';
2006
+ } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
2007
+ finally { busy.value = false; }
2008
+ }
2009
+ async function confirmed(result: Confirmation) {
2010
+ const intent = publication.value;
2011
+ closeTicket();
2012
+ if (intent) { retryPublish.value = { ...intent, requestId: result.requestId }; await publish(); }
2013
+ else message.value = 'Verified. Reload account status from the backend.';
2014
+ }
2015
+ onMounted(async () => {
2016
+ try {
2017
+ const value = await api('/api/session');
2018
+ if (value.authenticated !== true || typeof value.csrf !== 'string' || !value.csrf) {
2019
+ throw new Error('LOGIN_REQUIRED');
2020
+ }
2021
+ session.value = value;
2022
+ } catch (error) { if (!lifetime.signal.aborted) showError(error as Error); }
2023
+ });
2024
+ onUnmounted(() => { lifetime.abort(); closeTicket(); creation = null; retryPublish.value = null; });
2025
+ </script>
2026
+
2027
+ <template>
2028
+ <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('bind')">Bind phone</button>
2029
+ <button :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm')">Confirm</button>
2030
+ <button v-if="post" :disabled="busy || !!ticket || !session?.authenticated" @click="start('confirm', post)">Confirm publication</button>
2031
+ <button v-if="retryPublish" :disabled="busy || !!ticket" @click="publish">Retry the same publication</button>
2032
+ <AuthLinkBind v-if="ticket?.purpose === 'bind'" :ticket="ticket" :client="client" variant="B"
2033
+ @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
2034
+ @expired="finish('Expired; start a new request')" @error="showError" />
2035
+ <AuthLinkConfirm v-else-if="ticket?.purpose === 'confirm'" :ticket="ticket" :client="client" variant="B"
2036
+ @confirmed="confirmed" @close="finish('Closed')" @rejected="finish('Rejected')"
2037
+ @expired="finish('Expired; start a new request')" @error="showError" />
2038
+ <p role="status">{{ message }}</p>
2039
+ </template>
2040
+ ```
2041
+
2042
+ client.create が送るのは purpose のみです。postId/version が必要なら業務 API に fetch で送り、`validateTicket(response, 'confirm', authLinkOrigin)` で検証した後、監視には公式 client を使います。同じ作成の失敗ではキーを保持し、用途・記事・版が変わったときだけ新規キーにします。ticket は shallowRef、client は markRaw で保持します。
2043
+
2044
+ マウント時は既存要求を監視し、mounted/watch で新規作成しません。閉じる・拒否・期限切れでは ticket を消去し、通信エラーでは同じ要求を再試行できるよう表示を維持します。confirmed 後は表示を閉じ、独立した公開 API を呼びます。失敗時は同じ requestId/記事/版で再試行し、実状態をバックエンドから再取得します。サイト自身のログインフォーム・記事一覧・エラー翻訳は例から省略しています。
2045
+
2046
+ #### 6. 公開トランザクションで再検証
2047
+
2048
+ ブラウザーの confirmed イベントは許可証ではありません。Go は現在の session の保存済み確認結果を読み、purpose=confirm、intent=publish、記事の所有者、同一 postId/version、下書き状態、初回適用時の有効期限を検証します。記事更新と applied の記録を一括コミットします。同じ適用済み操作の再試行は重複更新・監査なしで成功を返します。別記事や編集後の版には新たな確認が必要です。AuthLink 確認結果に action、transactionHash、記事本文を追加しません。
2049
+
2050
+ #### 検証順序
2051
+
2052
+ サイトログイン → session → バックエンド AccountStatus(参照 CMS は overview 経由)→ 未登録なら bind → スマートフォンで承認 → 消費と状態再取得 → 下書き保存 → 記事/版の confirm → 承認 → 確認結果保存 → 公開 → 再試行・異なる版・拒否・期限切れ。実端末での承認は利用者が行い、コンパイルや模擬 HTTP 検証とは区別します。