@owlmeans/client-auth 0.1.18-rc.8 → 0.1.18

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.
Files changed (259) hide show
  1. package/README.md +283 -37
  2. package/agent-meta/manifest.json +50 -4
  3. package/agent-meta/skills/client-auth/SKILL.md +85 -23
  4. package/agent-meta/skills/client-auth/parts/login.md +77 -0
  5. package/agent-meta/skills/client-auth/parts/manager.md +37 -0
  6. package/build/components/dispatcher/component.d.ts.map +1 -1
  7. package/build/components/dispatcher/component.js +26 -6
  8. package/build/components/dispatcher/component.js.map +1 -1
  9. package/build/components/dispatcher/types.local.d.ts +7 -0
  10. package/build/components/dispatcher/types.local.d.ts.map +1 -0
  11. package/build/components/dispatcher/types.local.js +2 -0
  12. package/build/components/dispatcher/types.local.js.map +1 -0
  13. package/build/entrypoints.d.ts +7 -0
  14. package/build/entrypoints.d.ts.map +1 -0
  15. package/build/entrypoints.js +7 -0
  16. package/build/entrypoints.js.map +1 -0
  17. package/build/helper.d.ts +2 -1
  18. package/build/helper.d.ts.map +1 -1
  19. package/build/helper.js +28 -5
  20. package/build/helper.js.map +1 -1
  21. package/build/index.d.ts +1 -1
  22. package/build/index.d.ts.map +1 -1
  23. package/build/index.js +1 -1
  24. package/build/index.js.map +1 -1
  25. package/build/login/adopt/types.d.ts +26 -0
  26. package/build/login/adopt/types.d.ts.map +1 -0
  27. package/build/login/adopt/types.js +2 -0
  28. package/build/login/adopt/types.js.map +1 -0
  29. package/build/login/adopt.d.ts +7 -12
  30. package/build/login/adopt.d.ts.map +1 -1
  31. package/build/login/adopt.js +15 -14
  32. package/build/login/adopt.js.map +1 -1
  33. package/build/login/consts.d.ts +89 -4
  34. package/build/login/consts.d.ts.map +1 -1
  35. package/build/login/consts.js +93 -5
  36. package/build/login/consts.js.map +1 -1
  37. package/build/login/consts.local.d.ts +16 -0
  38. package/build/login/consts.local.d.ts.map +1 -0
  39. package/build/login/consts.local.js +30 -0
  40. package/build/login/consts.local.js.map +1 -0
  41. package/build/login/credit/types.d.ts +7 -0
  42. package/build/login/credit/types.d.ts.map +1 -0
  43. package/build/login/credit/types.js +2 -0
  44. package/build/login/credit/types.js.map +1 -0
  45. package/build/login/credit.d.ts +19 -0
  46. package/build/login/credit.d.ts.map +1 -0
  47. package/build/login/credit.js +58 -0
  48. package/build/login/credit.js.map +1 -0
  49. package/build/login/env/types.d.ts +35 -0
  50. package/build/login/env/types.d.ts.map +1 -0
  51. package/build/login/env/types.js +2 -0
  52. package/build/login/env/types.js.map +1 -0
  53. package/build/login/env.d.ts +5 -29
  54. package/build/login/env.d.ts.map +1 -1
  55. package/build/login/env.js +63 -80
  56. package/build/login/env.js.map +1 -1
  57. package/build/login/flow.d.ts +20 -0
  58. package/build/login/flow.d.ts.map +1 -0
  59. package/build/login/flow.js +39 -0
  60. package/build/login/flow.js.map +1 -0
  61. package/build/login/hook.d.ts +30 -4
  62. package/build/login/hook.d.ts.map +1 -1
  63. package/build/login/hook.js +53 -18
  64. package/build/login/hook.js.map +1 -1
  65. package/build/login/i18n/be.json +41 -0
  66. package/build/login/i18n/de.json +41 -0
  67. package/build/login/i18n/en.json +41 -0
  68. package/build/login/i18n/es.json +41 -0
  69. package/build/login/i18n/fr.json +41 -0
  70. package/build/login/i18n/pl.json +41 -0
  71. package/build/login/i18n/ru.json +41 -0
  72. package/build/login/i18n/uk.json +41 -0
  73. package/build/login/i18n.d.ts +2 -0
  74. package/build/login/i18n.d.ts.map +1 -0
  75. package/build/login/i18n.js +27 -0
  76. package/build/login/i18n.js.map +1 -0
  77. package/build/login/index.d.ts +20 -0
  78. package/build/login/index.d.ts.map +1 -1
  79. package/build/login/index.js +11 -0
  80. package/build/login/index.js.map +1 -1
  81. package/build/login/land/types.d.ts +52 -0
  82. package/build/login/land/types.d.ts.map +1 -0
  83. package/build/login/land/types.js +2 -0
  84. package/build/login/land/types.js.map +1 -0
  85. package/build/login/land.d.ts +6 -0
  86. package/build/login/land.d.ts.map +1 -0
  87. package/build/login/land.js +122 -0
  88. package/build/login/land.js.map +1 -0
  89. package/build/login/methods/types.d.ts +30 -0
  90. package/build/login/methods/types.d.ts.map +1 -0
  91. package/build/login/methods/types.js +2 -0
  92. package/build/login/methods/types.js.map +1 -0
  93. package/build/login/methods.d.ts +7 -0
  94. package/build/login/methods.d.ts.map +1 -0
  95. package/build/login/methods.js +87 -0
  96. package/build/login/methods.js.map +1 -0
  97. package/build/login/resume/types.d.ts +24 -0
  98. package/build/login/resume/types.d.ts.map +1 -0
  99. package/build/login/resume/types.js +2 -0
  100. package/build/login/resume/types.js.map +1 -0
  101. package/build/login/resume.d.ts +9 -0
  102. package/build/login/resume.d.ts.map +1 -0
  103. package/build/login/resume.js +38 -0
  104. package/build/login/resume.js.map +1 -0
  105. package/build/login/screen.d.ts +17 -0
  106. package/build/login/screen.d.ts.map +1 -0
  107. package/build/login/screen.js +66 -0
  108. package/build/login/screen.js.map +1 -0
  109. package/build/login/service.d.ts +6 -5
  110. package/build/login/service.d.ts.map +1 -1
  111. package/build/login/service.js +141 -15
  112. package/build/login/service.js.map +1 -1
  113. package/build/login/start/types.d.ts +29 -0
  114. package/build/login/start/types.d.ts.map +1 -0
  115. package/build/login/start/types.js +2 -0
  116. package/build/login/start/types.js.map +1 -0
  117. package/build/login/start.d.ts +5 -0
  118. package/build/login/start.d.ts.map +1 -0
  119. package/build/login/start.js +36 -0
  120. package/build/login/start.js.map +1 -0
  121. package/build/login/surrogate/types.d.ts +9 -0
  122. package/build/login/surrogate/types.d.ts.map +1 -0
  123. package/build/login/surrogate/types.js +2 -0
  124. package/build/login/surrogate/types.js.map +1 -0
  125. package/build/login/surrogate.d.ts +13 -0
  126. package/build/login/surrogate.d.ts.map +1 -0
  127. package/build/login/surrogate.js +33 -0
  128. package/build/login/surrogate.js.map +1 -0
  129. package/build/login/terms/types.d.ts +112 -0
  130. package/build/login/terms/types.d.ts.map +1 -0
  131. package/build/login/terms/types.js +2 -0
  132. package/build/login/terms/types.js.map +1 -0
  133. package/build/login/terms-config.d.ts +65 -0
  134. package/build/login/terms-config.d.ts.map +1 -0
  135. package/build/login/terms-config.js +2 -0
  136. package/build/login/terms-config.js.map +1 -0
  137. package/build/login/terms.d.ts +21 -0
  138. package/build/login/terms.d.ts.map +1 -0
  139. package/build/login/terms.js +235 -0
  140. package/build/login/terms.js.map +1 -0
  141. package/build/login/types.d.ts +223 -16
  142. package/build/login/types.d.ts.map +1 -1
  143. package/build/login/types.js +1 -16
  144. package/build/login/types.js.map +1 -1
  145. package/build/manager/components/authentication/control.d.ts.map +1 -1
  146. package/build/manager/components/authentication/control.js +3 -3
  147. package/build/manager/components/authentication/control.js.map +1 -1
  148. package/build/manager/components/authentication/types.d.ts +2 -2
  149. package/build/manager/components/authentication/types.d.ts.map +1 -1
  150. package/build/manager/components/tunnel-consumer.js +2 -2
  151. package/build/manager/components/tunnel-consumer.js.map +1 -1
  152. package/build/manager/entrypoints.d.ts +11 -0
  153. package/build/manager/entrypoints.d.ts.map +1 -0
  154. package/build/manager/entrypoints.js +17 -0
  155. package/build/manager/entrypoints.js.map +1 -0
  156. package/build/manager/errors.d.ts.map +1 -1
  157. package/build/manager/errors.js.map +1 -1
  158. package/build/manager/plugins/basic-ed25519.d.ts.map +1 -1
  159. package/build/manager/plugins/basic-ed25519.js +5 -0
  160. package/build/manager/plugins/basic-ed25519.js.map +1 -1
  161. package/build/manager/plugins/exports.d.ts +3 -0
  162. package/build/manager/plugins/exports.d.ts.map +1 -1
  163. package/build/manager/plugins/exports.js +2 -0
  164. package/build/manager/plugins/exports.js.map +1 -1
  165. package/build/manager/plugins/index.d.ts +2 -5
  166. package/build/manager/plugins/index.d.ts.map +1 -1
  167. package/build/manager/plugins/index.js +9 -1
  168. package/build/manager/plugins/index.js.map +1 -1
  169. package/build/manager/plugins/methods.d.ts +6 -0
  170. package/build/manager/plugins/methods.d.ts.map +1 -0
  171. package/build/manager/plugins/methods.js +65 -0
  172. package/build/manager/plugins/methods.js.map +1 -0
  173. package/build/manager/plugins/re-captcha.d.ts.map +1 -1
  174. package/build/manager/plugins/re-captcha.js +5 -0
  175. package/build/manager/plugins/re-captcha.js.map +1 -1
  176. package/build/manager/plugins/registry/types.d.ts +10 -0
  177. package/build/manager/plugins/registry/types.d.ts.map +1 -0
  178. package/build/manager/plugins/registry/types.js +2 -0
  179. package/build/manager/plugins/registry/types.js.map +1 -0
  180. package/build/manager/plugins/registry.d.ts +20 -0
  181. package/build/manager/plugins/registry.d.ts.map +1 -0
  182. package/build/manager/plugins/registry.js +23 -0
  183. package/build/manager/plugins/registry.js.map +1 -0
  184. package/build/manager/plugins/tunnel/wallet.d.ts.map +1 -1
  185. package/build/manager/plugins/tunnel-consumer.d.ts.map +1 -1
  186. package/build/manager/plugins/tunnel-consumer.js +10 -3
  187. package/build/manager/plugins/tunnel-consumer.js.map +1 -1
  188. package/build/manager/plugins/types.d.ts +53 -0
  189. package/build/manager/plugins/types.d.ts.map +1 -1
  190. package/build/service.d.ts.map +1 -1
  191. package/build/service.js +1 -1
  192. package/build/service.js.map +1 -1
  193. package/package.json +33 -30
  194. package/src/components/dispatcher/component.tsx +37 -14
  195. package/src/components/dispatcher/types.local.ts +7 -0
  196. package/src/entrypoints.ts +10 -0
  197. package/src/helper.ts +31 -5
  198. package/src/index.ts +1 -1
  199. package/src/login/adopt/types.ts +25 -0
  200. package/src/login/adopt.ts +21 -14
  201. package/src/login/consts.local.ts +34 -0
  202. package/src/login/consts.ts +105 -5
  203. package/src/login/credit/types.ts +6 -0
  204. package/src/login/credit.ts +69 -0
  205. package/src/login/env/types.ts +35 -0
  206. package/src/login/env.ts +60 -72
  207. package/src/login/flow.ts +44 -0
  208. package/src/login/hook.ts +58 -22
  209. package/src/login/i18n/be.json +41 -0
  210. package/src/login/i18n/de.json +41 -0
  211. package/src/login/i18n/en.json +41 -0
  212. package/src/login/i18n/es.json +41 -0
  213. package/src/login/i18n/fr.json +41 -0
  214. package/src/login/i18n/pl.json +41 -0
  215. package/src/login/i18n/ru.json +41 -0
  216. package/src/login/i18n/uk.json +41 -0
  217. package/src/login/i18n.ts +29 -0
  218. package/src/login/index.ts +21 -0
  219. package/src/login/land/types.ts +54 -0
  220. package/src/login/land.ts +149 -0
  221. package/src/login/methods/types.ts +32 -0
  222. package/src/login/methods.ts +109 -0
  223. package/src/login/resume/types.ts +24 -0
  224. package/src/login/resume.ts +45 -0
  225. package/src/login/screen.tsx +140 -0
  226. package/src/login/service.ts +181 -18
  227. package/src/login/start/types.ts +30 -0
  228. package/src/login/start.ts +44 -0
  229. package/src/login/surrogate/types.ts +9 -0
  230. package/src/login/surrogate.ts +37 -0
  231. package/src/login/terms/types.ts +126 -0
  232. package/src/login/terms-config.ts +55 -0
  233. package/src/login/terms.ts +315 -0
  234. package/src/login/types.ts +233 -17
  235. package/src/manager/README.md +37 -80
  236. package/src/manager/components/authentication/control.ts +3 -3
  237. package/src/manager/components/authentication/types.ts +2 -2
  238. package/src/manager/components/tunnel-consumer.tsx +2 -2
  239. package/src/manager/entrypoints.ts +18 -0
  240. package/src/manager/plugins/basic-ed25519.tsx +7 -0
  241. package/src/manager/plugins/exports.ts +3 -0
  242. package/src/manager/plugins/index.ts +11 -4
  243. package/src/manager/plugins/methods.ts +74 -0
  244. package/src/manager/plugins/re-captcha.tsx +7 -0
  245. package/src/manager/plugins/registry/types.ts +10 -0
  246. package/src/manager/plugins/registry.ts +33 -0
  247. package/src/manager/plugins/tunnel-consumer.tsx +12 -3
  248. package/src/manager/plugins/types.ts +54 -0
  249. package/src/service.ts +1 -1
  250. package/tests/context.ts +1 -2
  251. package/tests/i18n.spec.ts +68 -0
  252. package/tests/land.spec.ts +292 -0
  253. package/tests/login.spec.ts +332 -0
  254. package/tests/target.spec.ts +210 -0
  255. package/tests/terms-deferred.spec.ts +65 -0
  256. package/tests/terms.spec.ts +314 -0
  257. package/build/.gitkeep +0 -0
  258. package/src/manager/modules.ts +0 -18
  259. package/src/modules.ts +0 -13
@@ -1,10 +1,19 @@
1
- import { createLazyService } from '@owlmeans/context'
2
- import type { BasicConfig, BasicContext } from '@owlmeans/context'
3
- import { DEFAULT_ALIAS } from './consts.js'
4
- import { defaultLoginEnv } from './env.js'
5
- import { adoptToken } from './adopt.js'
6
- import { LoginOutcome } from './types.js'
7
- import type { LoginContext, LoginPlugin, LoginService, LoginServiceAppend } from './types.js'
1
+ import { createLazyService, type BasicConfig, type BasicContext } from '@owlmeans/context'
2
+ import type { CommonConfig } from '@owlmeans/config'
3
+ import type { ClientContext } from '@owlmeans/client'
4
+ import { flowLandingOf } from '@owlmeans/client-flow'
5
+ import { logger } from '@owlmeans/log'
6
+ import { DEFAULT_ALIAS, LOGIN_TARGET_TTL, LoginOutcome } from './consts.js'
7
+ import { UNSIGNED_OUTCOMES } from './consts.local.js'
8
+ import { loginEnvHelper } from './env.js'
9
+ import { loginTokenOf } from './adopt.js'
10
+ import { loginMethodsHelper } from './methods.js'
11
+ import type {
12
+ LoginContext, LoginLandingHook, LoginMethodSource, LoginNotifier, LoginPlugin, LoginPrecondition,
13
+ LoginRequest, LoginScreenComponent, LoginService, LoginServiceAppend, LoginStep
14
+ } from './types.js'
15
+
16
+ const log = logger('client-auth:login')
8
17
 
9
18
  /**
10
19
  * Login service = plugin host. It holds a registry of login plugins and, on every facade call,
@@ -19,15 +28,61 @@ import type { LoginContext, LoginPlugin, LoginService, LoginServiceAppend } from
19
28
  * stage. `context.service()` throws for an uninitialized non-lazy service, which would make
20
29
  * `ensureLoginService` fail exactly where apps are told to call it.
21
30
  *
22
- * `begin` is deliberately NOT `async`. An async facade method defers the plugin's body past a
23
- * microtask boundary, and a `window.open` that lands after the user gesture has finished being
24
- * handled is eaten by the popup blocker. The same rule binds every plugin's own `begin`.
31
+ * `begin` and `logout` are deliberately NOT `async`. An async facade method defers the plugin's
32
+ * body past a microtask boundary, and a `window.open` that lands after the user gesture has
33
+ * finished being handled is eaten by the popup blocker. The same rule binds every plugin's own
34
+ * `begin` and `logout`, and it is why a precondition must be synchronous too.
25
35
  */
26
36
  export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService => {
27
37
  const plugins: LoginPlugin[] = []
38
+ const preconditions: LoginPrecondition[] = []
39
+ const methodSources: LoginMethodSource[] = []
40
+ const steps: LoginStep[] = []
41
+ const hooks: LoginLandingHook[] = []
42
+ let screen: LoginScreenComponent | null = null
43
+ let notifier: LoginNotifier | null = null
28
44
 
29
45
  const ctx = (): LoginContext => service.ctx as LoginContext
30
46
 
47
+ /**
48
+ * Park `request.target` for the post-sign-in landing, and hand back the request whose continuation
49
+ * waits for that write.
50
+ *
51
+ * The write is STARTED here and never awaited on the way in: `begin` must reach the plugin's
52
+ * `window.open` inside the gesture. Only the continuation waits — the redirect plugin's in-app hop
53
+ * to the dispatcher and the surrogate's hop after the popup has handed its token back — so the
54
+ * dispatcher that resumes the landing always finds the record, whatever the storage behind it.
55
+ * A write that fails costs the landing, never the sign-in.
56
+ */
57
+ const parkTarget = (
58
+ ctx: LoginContext, request: LoginRequest
59
+ ): [LoginRequest, Promise<boolean> | null] => {
60
+ if (request.target == null || request.target === '') {
61
+ return [request, null]
62
+ }
63
+ const parked = flowLandingOf(ctx as unknown as ClientContext).suspendLanding(
64
+ { entrypoint: request.target, query: {} },
65
+ { expiresAt: Date.now() + LOGIN_TARGET_TTL }
66
+ ).catch((error: unknown) => {
67
+ log.warn('Could not park the sign-in target; the landing falls back', {
68
+ target: request.target, error,
69
+ })
70
+
71
+ return false
72
+ })
73
+ const navigate = request.navigate
74
+
75
+ return [
76
+ navigate == null ? request : {
77
+ ...request, navigate: async () => {
78
+ await parked
79
+ await navigate()
80
+ },
81
+ },
82
+ parked,
83
+ ]
84
+ }
85
+
31
86
  const service: LoginService = createLazyService<LoginService>(alias, {
32
87
  registerPlugin: (plugin: LoginPlugin) => {
33
88
  const existing = plugins.findIndex(candidate => candidate.alias === plugin.alias)
@@ -38,8 +93,59 @@ export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService =>
38
93
  plugins.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
39
94
  },
40
95
 
96
+ registerPrecondition: (precondition: LoginPrecondition) => {
97
+ const existing = preconditions.findIndex(candidate => candidate.alias === precondition.alias)
98
+ if (existing >= 0) {
99
+ preconditions.splice(existing, 1)
100
+ }
101
+ preconditions.push(precondition)
102
+ preconditions.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
103
+ },
104
+
105
+ registerMethodSource: (source: LoginMethodSource) => {
106
+ const existing = methodSources.findIndex(candidate => candidate.alias === source.alias)
107
+ if (existing >= 0) {
108
+ methodSources.splice(existing, 1)
109
+ }
110
+ methodSources.push(source)
111
+ },
112
+
113
+ methods: methodCtx => loginMethodsHelper.resolveLoginMethods(
114
+ methodCtx,
115
+ (methodCtx.context.cfg as CommonConfig).security?.auth?.login,
116
+ methodSources
117
+ ),
118
+
119
+ registerScreen: value => { screen = value },
120
+
121
+ screen: () => screen,
122
+
123
+ registerNotifier: value => { notifier = value },
124
+
125
+ registerStep: (step: LoginStep) => {
126
+ const existing = steps.findIndex(candidate => candidate.alias === step.alias)
127
+ if (existing >= 0) {
128
+ steps.splice(existing, 1)
129
+ }
130
+ steps.push(step)
131
+ steps.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
132
+ },
133
+
134
+ steps: () => [...steps],
135
+
136
+ onLanded: (hook: LoginLandingHook) => {
137
+ const existing = hooks.findIndex(candidate => candidate.alias === hook.alias)
138
+ if (existing >= 0) {
139
+ hooks.splice(existing, 1)
140
+ }
141
+ hooks.push(hook)
142
+ hooks.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
143
+ },
144
+
145
+ landingHooks: () => [...hooks],
146
+
41
147
  plugin: env => {
42
- const environment = env ?? defaultLoginEnv()
148
+ const environment = env ?? loginEnvHelper.defaultLoginEnv()
43
149
  const plugin = plugins.find(
44
150
  candidate => candidate.match?.(environment, service.ctx as LoginContext | undefined) ?? true
45
151
  )
@@ -49,32 +155,89 @@ export const makeLoginService = (alias: string = DEFAULT_ALIAS): LoginService =>
49
155
  return plugin
50
156
  },
51
157
 
52
- env: () => defaultLoginEnv(),
158
+ env: () => loginEnvHelper.defaultLoginEnv(),
53
159
 
54
160
  enter: () => {
55
- const env = defaultLoginEnv()
161
+ const env = loginEnvHelper.defaultLoginEnv()
56
162
  service.plugin(env).enter?.(ctx(), env)
57
163
  },
58
164
 
59
165
  begin: request => {
60
- const env = defaultLoginEnv()
166
+ const env = loginEnvHelper.defaultLoginEnv()
167
+ // Synchronously, before anything can open a window: a precondition that refuses leaves the
168
+ // user exactly where `Gesture` describes — unable to proceed until they act again.
169
+ for (const precondition of preconditions) {
170
+ if (!precondition.check(ctx(), request, env)) {
171
+ return Promise.resolve(LoginOutcome.Gesture)
172
+ }
173
+ }
174
+
175
+ // Parked only past the preconditions: a refused attempt leaves no landing behind for the
176
+ // next, unrelated sign-in to resume.
177
+ const [parkedRequest, parked] = parkTarget(ctx(), request)
178
+
179
+ // Notified here, in the facade, rather than by each caller: `useLogin` fires the flow and
180
+ // forgets the result, and this is the one place every caller — the header hook and the
181
+ // sign-in screen's method buttons alike — passes through on the way to a settled outcome.
182
+ return service.plugin(env).begin(ctx(), parkedRequest, env).then(outcome => {
183
+ if (parked != null && UNSIGNED_OUTCOMES.has(outcome)) {
184
+ void parked.then(async () => {
185
+ await flowLandingOf(ctx() as unknown as ClientContext).discardSuspendedLanding()
186
+ })
187
+ }
188
+ notifier?.(outcome, env)
61
189
 
62
- return service.plugin(env).begin(ctx(), request, env)
190
+ return outcome
191
+ })
63
192
  },
64
193
 
65
194
  authorize: async url => {
66
- const env = defaultLoginEnv()
195
+ const env = loginEnvHelper.defaultLoginEnv()
67
196
 
68
197
  return await service.plugin(env).authorize(ctx(), url, env)
69
198
  },
70
199
 
71
200
  complete: async token => {
72
- const env = defaultLoginEnv()
201
+ const env = loginEnvHelper.defaultLoginEnv()
73
202
 
74
203
  return await service.plugin(env).complete(ctx(), token, env)
75
204
  },
76
205
 
77
- adopt: async token => { await adoptToken(ctx(), token) },
206
+ resume: async token => {
207
+ const env = loginEnvHelper.defaultLoginEnv()
208
+ // Absent means "keep it and carry on" — which is what an ordinary tab has always done, and
209
+ // is why the redirect plugin implements nothing here.
210
+ return await service.plugin(env).resume?.(ctx(), token, env) ?? LoginOutcome.Passed
211
+ },
212
+
213
+ logout: request => {
214
+ const env = loginEnvHelper.defaultLoginEnv()
215
+ const plugin = service.plugin(env)
216
+ if (plugin.logout == null) {
217
+ // A plugin with no logout mechanic still has to end the session it started.
218
+ return loginTokenOf(ctx()).revokeToken().then(async () => {
219
+ await request.navigate?.()
220
+
221
+ return LoginOutcome.Passed
222
+ })
223
+ }
224
+
225
+ return plugin.logout(ctx(), request, env).then(outcome => {
226
+ notifier?.(outcome, env)
227
+
228
+ return outcome
229
+ })
230
+ },
231
+
232
+ logoutComplete: async () => {
233
+ const env = loginEnvHelper.defaultLoginEnv()
234
+
235
+ return await service.plugin(env).logoutComplete?.(ctx(), env) ?? LoginOutcome.Passed
236
+ },
237
+
238
+ adopt: async token => { await loginTokenOf(ctx()).adoptToken(token) },
239
+
240
+ revoke: async () => { await loginTokenOf(ctx()).revokeToken() },
78
241
  })
79
242
 
80
243
  return service
@@ -0,0 +1,30 @@
1
+ import type { LoginOutcome } from '../consts.js'
2
+
3
+ /** What a "Log in" control hands `startLogin`: where the flow starts, where it lands, how to move. */
4
+ export interface LoginStart {
5
+ /** The resolved dispatcher path — where the login flow starts. */
6
+ url: string
7
+ /** Entrypoint alias to land on once signed in; absent means the ordinary landing. */
8
+ target?: string
9
+ /** The in-app navigation, supplied by a component because only a component may hold one. */
10
+ go: (alias: string) => void | Promise<void>
11
+ }
12
+
13
+ /** The decision behind a "Log in" control, bound to the application context. */
14
+ export interface LoginStartHelper {
15
+ /**
16
+ * What a "Log in" control does when clicked — the decision behind `useLogin`, kept free of React so
17
+ * it is the same rule wherever a control is rendered.
18
+ *
19
+ * - A target and a session already here: go straight to the target — there is nobody to sign in.
20
+ * - Otherwise: begin a sign-in whose continuation is ALWAYS the dispatcher, never the target. A
21
+ * target travels as `LoginRequest.target`, which the facade parks for the landing; the
22
+ * dispatcher's own post-sign-in landing (`landAfterLogin` — landing hooks, pending steps such as a
23
+ * consent screen, then the parked target) brings the person there signed in. Navigating to a
24
+ * guarded target instead is what used to land a person on it signed out.
25
+ *
26
+ * NOT async, and nothing is awaited before `begin`: the surrogate plugin opens its window inside
27
+ * this call, and a window opened after the gesture has been handled is eaten by the popup blocker.
28
+ */
29
+ startLogin: (start: LoginStart) => Promise<LoginOutcome>
30
+ }
@@ -0,0 +1,44 @@
1
+ import { DISPATCHER } from '@owlmeans/auth'
2
+ import type { AuthService } from '@owlmeans/auth-common'
3
+ import { DEFAULT_ALIAS as AUTH_ALIAS } from '../consts.js'
4
+ import { LOGIN_SERVICE, LoginOutcome } from './consts.js'
5
+ import type { LoginContext, LoginService } from './types.js'
6
+ import { memoHelper } from '@owlmeans/context'
7
+ import type { LoginStartHelper } from './start/types.js'
8
+
9
+ export const makeLoginStartHelper = (ctx: LoginContext): LoginStartHelper => {
10
+ /**
11
+ * Whether this document already holds a session, answered WITHOUT awaiting anything.
12
+ *
13
+ * Read off the auth service's in-memory token, which every guard and `authenticated()` call fills.
14
+ * A session not yet read from storage answers `false` and costs nothing: the sign-in it starts finds
15
+ * the session at the dispatcher, which lands on the parked target all the same.
16
+ */
17
+ const holdsSession = (): boolean => {
18
+ if (!ctx.hasService(AUTH_ALIAS)) {
19
+ return false
20
+ }
21
+ const token = ctx.service<AuthService>(AUTH_ALIAS).token
22
+
23
+ return token != null && token !== ''
24
+ }
25
+
26
+ const startLogin: LoginStartHelper['startLogin'] = start => {
27
+ const { url, target, go } = start
28
+ if (target != null && target !== '' && holdsSession()) {
29
+ return Promise.resolve(go(target)).then(() => LoginOutcome.Handled)
30
+ }
31
+
32
+ return ctx.service<LoginService>(LOGIN_SERVICE).begin({
33
+ url,
34
+ target,
35
+ // Only a component may call `useNavigate`, so the in-app continuation is handed to the
36
+ // plugin rather than reinvented by it.
37
+ navigate: () => { void go(DISPATCHER) },
38
+ })
39
+ }
40
+
41
+ return { startLogin }
42
+ }
43
+
44
+ export const loginStartOf = memoHelper.oncePer(makeLoginStartHelper)
@@ -0,0 +1,9 @@
1
+ import type { LoginIntent } from '../consts.js'
2
+
3
+ export interface SurrogateTarget {
4
+ intent: LoginIntent
5
+ /** The address the surrogate should actually run, once it is one window up. */
6
+ next?: string
7
+ /** The method the user already chose in the opener. */
8
+ method?: string
9
+ }
@@ -0,0 +1,37 @@
1
+ import { DISPATCHER_SURROGATE } from '@owlmeans/auth'
2
+ import type { ClientEntrypoint } from '@owlmeans/client-entrypoint'
3
+ import { LOGIN_INTENT_QUERY, LOGIN_METHOD_QUERY, LOGIN_NEXT_QUERY } from './consts.js'
4
+ import type { LoginContext } from './types.js'
5
+ import type { SurrogateTarget } from './surrogate/types.js'
6
+
7
+ /**
8
+ * Where a surrogate window opens.
9
+ *
10
+ * Returns `null` when the application's entrypoint list predates the surrogate route — an app
11
+ * built against an older `@owlmeans/auth-common` has no such entrypoint, and the caller then falls
12
+ * back to opening the dispatcher directly, which is exactly what it used to do. That fallback is
13
+ * the whole compatibility story for already-deployed applications, so it must never be removed in
14
+ * favour of throwing.
15
+ */
16
+ export const surrogatePath = (ctx: LoginContext, target: SurrogateTarget): string | null => {
17
+ if (!ctx.hasEntrypoint(DISPATCHER_SURROGATE)) {
18
+ return null
19
+ }
20
+ let path: string
21
+ try {
22
+ path = ctx.entrypoint<ClientEntrypoint>(DISPATCHER_SURROGATE).path()
23
+ } catch {
24
+ return null
25
+ }
26
+
27
+ const query = new URLSearchParams()
28
+ query.set(LOGIN_INTENT_QUERY, target.intent)
29
+ if (target.next != null && target.next !== '') {
30
+ query.set(LOGIN_NEXT_QUERY, target.next)
31
+ }
32
+ if (target.method != null && target.method !== '') {
33
+ query.set(LOGIN_METHOD_QUERY, target.method)
34
+ }
35
+
36
+ return `${path}?${query.toString()}`
37
+ }
@@ -0,0 +1,126 @@
1
+ import type { LoginTermsConfig } from '@owlmeans/config'
2
+ import type { LoginContext } from '../types.js'
3
+
4
+ /** One document (or notice) a sign-in screen links to. */
5
+ export interface ResolvedTermsDocument {
6
+ key: string
7
+ href: string
8
+ /** Published translations, selected at render/record time; `href` remains canonical. */
9
+ hrefMap?: Record<string, string>
10
+ /** A caller's own label, already resolved to one string — only ever set for a custom document. */
11
+ label?: string
12
+ /** A locale map for the label, when the config supplied one — resolved by the renderer, not here. */
13
+ labelMap?: Record<string, string>
14
+ /** Translation key under `login.terms.*`, when this document has one. */
15
+ i18nKey?: string
16
+ revisedAt?: string
17
+ /** Extra interpolation values for `i18nKey`'s translation (e.g. `{ product: 'Acme' }`). */
18
+ params?: Record<string, string>
19
+ }
20
+
21
+ export interface ResolvedTerms {
22
+ required: boolean
23
+ terms: string
24
+ privacy: string
25
+ cookies?: string
26
+ version: string
27
+ /** What the checkbox agrees to — terms, then billing/product when configured, then custom. */
28
+ documents: ResolvedTermsDocument[]
29
+ /** What is merely DISCLOSED, never consented to — privacy, plus cookies per the rule below. */
30
+ notices: ResolvedTermsDocument[]
31
+ /** The latest `revisedAt` among {@link documents}. Only set when `showRevision` is true. */
32
+ revisedAt?: string
33
+ showRevision: boolean
34
+ }
35
+
36
+ /** One document as `@owlmeans/marketing-consent`'s `TermsDocumentRef` shape — structurally, with
37
+ * no dependency on that package (client-auth is a layer below it). */
38
+ export interface TermsAcceptanceRef {
39
+ key: string
40
+ href: string
41
+ revisedAt?: string
42
+ }
43
+
44
+ /** One fragment of an interpolated terms sentence — plain text, or a link to a document. */
45
+ export interface TermsSentencePart {
46
+ text: string
47
+ href?: string
48
+ documentKey?: string
49
+ }
50
+
51
+ /** What a sign-in-time terms acceptance sends the server. */
52
+ export interface LoginTermsAcceptance {
53
+ documents: TermsAcceptanceRef[]
54
+ notices: TermsAcceptanceRef[]
55
+ version: string
56
+ locale?: string
57
+ }
58
+
59
+ /** The documents a sign-in agrees to: their resolution, the local acceptance record and the sentence. */
60
+ export interface LoginTermsHelper {
61
+ /** The configured documents and notices, with the version acceptance is recorded against; null when terms are off. */
62
+ resolveTerms: (cfg?: LoginTermsConfig | false) => ResolvedTerms | null
63
+ /**
64
+ * Whether this browser has already agreed to exactly these documents.
65
+ *
66
+ * `localStorage` and not a cookie: the record is a UI convenience, it never travels to a server,
67
+ * and it must be readable by the surrogate login window — which is same-origin, so it sees the
68
+ * opener's acceptance and does not ask twice. A preview iframe on a different origin gets its own
69
+ * partition and therefore its own acceptance, which is correct rather than unfortunate.
70
+ */
71
+ termsAccepted: (resolved: ResolvedTerms | null) => boolean
72
+ /** Record (or, with `accepted: false`, forget) this browser's agreement to these documents. */
73
+ acceptTerms: (resolved: ResolvedTerms | null, accepted: boolean) => void
74
+ /**
75
+ * A document's own label: a caller's literal `label`, else its `labelMap` for the current locale,
76
+ * else its `i18nKey` translated — each with `params` (e.g. `{ product: 'Acme' }`) interpolated
77
+ * afterwards, since the `translate` contract every renderer shares is a plain
78
+ * `(key, defaultValue) => string` with no interpolation option of its own.
79
+ *
80
+ * The one label resolver every renderer of a terms sentence uses — `FallbackLoginScreen` here, and
81
+ * `@owlmeans/web-panel`'s `LoginTerms` — so the English fallbacks and the resolution order live in
82
+ * exactly one place instead of two hand-kept copies.
83
+ */
84
+ termsLabelResolver: (
85
+ translate: (key: string, defaultValue: string) => string, locale: string | undefined
86
+ ) => (doc: ResolvedTermsDocument) => string
87
+ /** Explicit locale destination, then base language, then canonical href; never rewrites paths. */
88
+ termsHrefOf: (doc: ResolvedTermsDocument, locale?: string) => string
89
+ /**
90
+ * What a sign-in-time terms acceptance sends the server — structurally
91
+ * `@owlmeans/marketing-consent`'s `TermsAcceptance`, so `client.recordTerms(termsAcceptanceOf(...))`
92
+ * type-checks with no dependency in this direction. Shared by `termsRecorder` (the sign-in screen's
93
+ * own local acceptance, copied at landing) and a Terms-mode consent screen recording the box it
94
+ * just showed.
95
+ */
96
+ termsAcceptanceOf: (
97
+ resolved: Pick<ResolvedTerms, 'documents' | 'notices' | 'version'>, locale?: string
98
+ ) => LoginTermsAcceptance
99
+ /**
100
+ * Whether the Terms confirmation has been moved off the sign-in screen onto a registered step.
101
+ *
102
+ * True only when a step both DECLARES `confirmsTerms` and is BOUND (`ctx.hasEntrypoint`) — an app
103
+ * that registers the step (e.g. `appendMarketingConsent({ terms: 'step' })`) but never binds its
104
+ * screen (an older target, a partial import) keeps the sign-in checkbox, fail-closed: a person must
105
+ * never find the confirmation missing from both places at once.
106
+ *
107
+ * Reads the service directly (`ctx.hasService`), never `ensureLoginService` — that registers an
108
+ * empty host as a side effect, which a render-time check must not do.
109
+ */
110
+ termsDeferred: (ctx: LoginContext) => boolean
111
+ /**
112
+ * Interpolate a translated sentence template around its document list(s).
113
+ *
114
+ * Supports `{{documents}}` (→ `resolved.documents`) and `{{notices}}` (→ `resolved.notices`), and
115
+ * — for backward compatibility with an older template string — the legacy `{{terms}}`, `{{privacy}}`
116
+ * and `{{cookies}}` placeholders, each resolved to its one matching document. Framework-agnostic on
117
+ * purpose: it returns plain data, never JSX, so both `@owlmeans/web-panel` and the plain fallback
118
+ * screen can turn the parts into their own markup.
119
+ */
120
+ termsSentence: (
121
+ template: string,
122
+ resolved: Pick<ResolvedTerms, 'documents' | 'notices'>,
123
+ locale: string | undefined,
124
+ resolveLabel: (doc: ResolvedTermsDocument) => string
125
+ ) => TermsSentencePart[]
126
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Extra `LoginTermsConfig` fields, added by TypeScript module augmentation rather than by editing
3
+ * `@owlmeans/config` itself.
4
+ *
5
+ * `@owlmeans/config` has roughly 85 dependents, and every one of them sits in the same release
6
+ * closure — a config bump ripples through every package that pins a range against it, whether or
7
+ * not it touches sign-in. The fields below are needed by exactly one consumer family (the sign-in
8
+ * screen and its terms confirmation, all downstream of `@owlmeans/client-auth`), so they are
9
+ * declared here instead, merged onto the base interface by TypeScript's own declaration merging.
10
+ *
11
+ * This file must be part of whatever a consumer's own compilation actually loads, or the merge
12
+ * never happens — an ambient augmentation applies only once the compiler has read the file that
13
+ * declares it. `@owlmeans/client-auth/login`'s barrel (`./index.ts`) re-exports this module for
14
+ * exactly that reason, so ANY import from `@owlmeans/client-auth/login` — which every application
15
+ * wiring sign-in already has — pulls this augmentation into that program. An app that constructs a
16
+ * `LoginTermsConfig` literal in a file that imports NOTHING from `@owlmeans/client-auth/login`
17
+ * still benefits as long as some other file in the same `tsc` program does; if none does (an
18
+ * isolated leaf package that only ever imports `@owlmeans/config`), add
19
+ * `import type {} from '@owlmeans/client-auth/login'` to the file building the literal — an
20
+ * import with an empty type list, kept only to pull this file into that program's graph.
21
+ *
22
+ * `export {}` below is required, not decorative: without at least one top-level import/export this
23
+ * file is a SCRIPT to TypeScript, not a MODULE, and `declare module '@owlmeans/config'` inside a
24
+ * script augments the global scope instead of merging onto the named module's exports.
25
+ */
26
+ declare module '@owlmeans/config' {
27
+ interface LoginTermsConfig {
28
+ /** `documents[].key` (or a custom document's own key) → the ISO date it was last revised. */
29
+ revisions?: Record<string, string>
30
+ /**
31
+ * Document/notice key → locale → published href. Opt-in; omitted locales use the canonical
32
+ * href. Renderers and acceptance evidence resolve the same current locale, while these
33
+ * translated destinations leave the canonical agreement version and revisions unchanged.
34
+ */
35
+ localizedHrefs?: Record<string, Record<string, string>>
36
+ /** Off by default. A bare string is `{ href }`. */
37
+ billing?: string | { href: string, revisedAt?: string } | false
38
+ /** Off by default. `name` is interpolated into the `login.terms.product` translation. */
39
+ product?: { name: string, href: string, revisedAt?: string } | false
40
+ /**
41
+ * Custom documents beyond terms/billing/product, in the order they are agreed to.
42
+ *
43
+ * `label` may be a locale map (`{ en: '...', fr: '...' }`) — resolved at RENDER time, by
44
+ * whichever renderer has the current locale, never here: this file stays locale-free.
45
+ */
46
+ documents?: Array<{
47
+ key: string, href: string, label?: string | Record<string, string>, i18nKey?: string,
48
+ revisedAt?: string,
49
+ }>
50
+ /** Show the latest revision date beneath the confirmation. Default false. */
51
+ showRevision?: boolean
52
+ }
53
+ }
54
+
55
+ export { }