@auth-link/web 0.0.2 → 0.0.4

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