From 7fdaea73c5c67565202e19d6182fc215427919c3 Mon Sep 17 00:00:00 2001 From: memdmp Date: Tue, 19 Aug 2025 20:40:19 +0000 Subject: feat: oidc attempt 1 --- src/app.css | 31 ++ src/app.d.ts | 16 + src/app.html | 11 + src/cs16.css | 761 +++++++++++++++++++++++++++++++++++ src/hooks.server.ts | 32 ++ src/lib/assets/favicon.svg | 1 + src/lib/auth.server.ts | 73 ++++ src/lib/index.ts | 1 + src/lib/oncePromise.ts | 30 ++ src/routes/+error.svelte | 6 + src/routes/+layout.server.ts | 5 + src/routes/+layout.svelte | 23 ++ src/routes/+page.svelte | 26 ++ src/routes/login/+server.ts | 55 +++ src/routes/login/callback/+server.ts | 80 ++++ 15 files changed, 1151 insertions(+) create mode 100644 src/app.css create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/cs16.css create mode 100644 src/hooks.server.ts create mode 100644 src/lib/assets/favicon.svg create mode 100644 src/lib/auth.server.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/oncePromise.ts create mode 100644 src/routes/+error.svelte create mode 100644 src/routes/+layout.server.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/login/+server.ts create mode 100644 src/routes/login/callback/+server.ts (limited to 'src') diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..a451f07 --- /dev/null +++ b/src/app.css @@ -0,0 +1,31 @@ +@import 'tailwindcss'; + +@layer base { + @import './cs16.css'; + + html { + font-size: 21px; + } + + body { + padding: 40px; + max-width: 800px; + margin: auto; + margin: 0 max(0px, round(50vw - (800px/2), 1px)); + } + + ::selection { + background-color: #958831; + color: white; + } + + @media (max-width: 400px) { + body { + padding: 20px; + } + } + + a:not(.default) { + @apply hover:underline text-[#baab44]; + } +} diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..cfeb8a5 --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,16 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +type Session = unknown; +declare global { + namespace App { + // interface Error {} + interface Locals { + auth: () => Promise; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..b0b3788 --- /dev/null +++ b/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/cs16.css b/src/cs16.css new file mode 100644 index 0000000..108e9d2 --- /dev/null +++ b/src/cs16.css @@ -0,0 +1,761 @@ +:root { + --bg: #4a5942; + --secondary-bg: #3e4637; + --accent: #c4b550; + --secondary-accent: #958831; + --text: #dedfd6; + --secondary-text: #d8ded3; + --text-3: #a0aa95; + --border-light: #8c9284; + --border-dark: #292c21; + --disabled-text: #292c21; + --disabled-text-shadow: #75806f; + --outline: #000; + --slider: #7f8c7f; + --slider-bg: #1f1f1f; + --scrollbar-track: #5a6a50; +} +@font-face { + font-family: ArialPixel; + font-style: normal; + font-weight: 400; + src: url(https://cdn.jsdelivr.net/gh/ekmas/cs16.css@main/ArialPixel.ttf) + format('truetype'); +} +.cs { + & { + background-color: var(--bg); + color: var(--text); + font-family: ArialPixel, system-ui, sans-serif; + font-weight: 400; + line-height: 1.5; + } + *, + :after, + :before { + box-sizing: border-box; + } + * { + margin: 0; + padding: 0; + } + button, + input, + select, + textarea { + font: inherit; + } + h1, + h2, + h3, + h4, + h5, + h6, + p { + font-weight: 400; + overflow-wrap: break-word; + } + ::-webkit-scrollbar { + width: 18px; + width: 1.125rem; + } + ::-webkit-scrollbar-track { + background-color: var(--scrollbar-track); + border: 1px solid var(--border-dark); + border-left: 0; + width: 18px; + width: 1.125rem; + } + ::-webkit-scrollbar-thumb { + background-color: var(--bg); + border: 1px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) + var(--border-light); + width: 17px; + width: 1.0625rem; + } + ::-webkit-scrollbar-corner { + background-color: var(--scrollbar-track); + } + ::-webkit-scrollbar-button:vertical:end:increment, + ::-webkit-scrollbar-button:vertical:start:decrement { + display: block; + } + ::-webkit-scrollbar-button:vertical:end:decrement, + ::-webkit-scrollbar-button:vertical:end:increment, + ::-webkit-scrollbar-button:vertical:start:decrement, + ::-webkit-scrollbar-button:vertical:start:increment { + background-repeat: no-repeat; + height: 17px; + height: 1.0625rem; + } + ::-webkit-scrollbar-button:vertical:start { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='16' viewBox='0 0 15 16'%3E%3Cpath fill='%23a0aa95' d='M5 9h1v1H5m1-1h1v1H6m0-2h1v1H6m1 0h1v1H7m0-2h1v1H7m0-2h1v1H7m1 1h1v1H8m0-2h1v1H8m0-2h1v1H8m0-2h1v1H8m1 2h1v1H9m0-2h1v1H9m0-2h1v1H9m1 1h1v1h-1m0-2h1v1h-1m1 0h1v1h-1'/%3E%3C/svg%3E"); + } + ::-webkit-scrollbar-button:vertical:end, + ::-webkit-scrollbar-button:vertical:start { + border: 1px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) + var(--border-light); + } + ::-webkit-scrollbar-button:vertical:end:active, + ::-webkit-scrollbar-button:vertical:start:active { + border-color: var(--border-dark) var(--border-light) var(--border-light) + var(--border-dark); + } + ::-webkit-scrollbar-button:vertical:start:active, + ::-webkit-scrollbar-button:vertical:start:hover { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='16' viewBox='0 0 15 16'%3E%3Cpath fill='%23fff' d='M5 9h1v1H5m1-1h1v1H6m1-1h1v1H7m1-1h1v1H8m1-1h1v1H9m1-1h1v1h-1m1-1h1v1h-1M6 8h1v1H6m1-1h1v1H7m1-1h1v1H8m1-1h1v1H9m1-1h1v1h-1M7 7h1v1H7m1-1h1v1H8m1-1h1v1H9M8 6h1v1H8'/%3E%3C/svg%3E"); + } + ::-webkit-scrollbar-button:vertical:end { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='16' viewBox='0 0 15 16'%3E%3Cpath fill='%23a0aa95' d='M5 6h1v1H5m1-1h1v1H6m0 0h1v1H6m1-1h1v1H7m0 0h1v1H7m1 0h1v1H8m0-2h1v1H8m0-2h1v1H8M7 6h1v1H7m1-1h1v1H8m3-1h1v1h-1m-1-1h1v1h-1M9 6h1v1H9m0 1h1v1H9m0-2h1v1H9m1-1h1v1h-1'/%3E%3C/svg%3E"); + } + ::-webkit-scrollbar-button:vertical:end:active { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='16' viewBox='0 0 15 16'%3E%3Cpath fill='%23fff' d='M5 6h1v1H5m6-1h1v1h-1m-1-1h1v1h-1M9 6h1v1H9M8 6h1v1H8M7 6h1v1H7M6 6h1v1H6m0 0h1v1H6m4-1h1v1h-1M9 7h1v1H9M8 7h1v1H8M7 7h1v1H7m0 0h1v1H7m2-1h1v1H9M8 9h1v1H8m0-2h1v1H8'/%3E%3C/svg%3E"); + } + .cs-btn, + button:not(.default-btn), + input[type='submit']:not(.default-btn) { + background-color: var(--bg); + border: 1px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) + var(--border-light); + color: #fff; + font-size: 16px; + font-size: 1rem; + line-height: 15px; + line-height: 0.9375rem; + padding: 0.25rem 0.3125rem 0.1875rem; + user-select: none; + &.close { + background: no-repeat 50%; + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'%3E%3Cpath fill='%238c9284' d='M3 3h1v1H3m1-1h1v1H4M3 4h1v1H3m1-1h1v1H4m0 0h1v1H4m1-1h1v1H5m0-2h1v1H5m0 1h1v1H5m1-1h1v1H6m0-2h1v1H6m0 1h1v1H6m0 0h1v1H6m1-1h1v1H7m0-2h1v1H7m0-2h1v1H7m1-1h1v1H8m0 0h1v1H8m0 0h1v1H8m0-4h1v1H8m1-1h1v1H9m0 0h1v1H9m0-3h1v1H9m1-1h1v1h-1m0 0h1v1h-1m0-3h1v1h-1m1-1h1v1h-1m0 0h1v1h-1M9 8h1v1H9m0 0h1v1H9M8 9h1v1H8m2-1h1v1h-1m0 0h1v1h-1m-1-1h1v1H9m2-1h1v1h-1m0 0h1v1h-1m-1-1h1v1h-1M5 8h1v1H5m0 0h1v1H5m1-1h1v1H6M4 9h1v1H4m0 0h1v1H4m1-1h1v1H5m-2-1h1v1H3m0 0h1v1H3m1-1h1v1H4'/%3E%3C/svg%3E"); + height: 18px; + height: 1.125rem; + padding: 0; + width: 18px; + width: 1.125rem; + } + &:focus-visible { + outline: 1px solid var(--outline); + outline: 0.0625rem solid var(--outline); + padding: 0.1875rem 0.25rem 0.125rem; + &.close { + outline: 0; + padding: 0; + } + } + &:active { + border-color: var(--border-dark) var(--border-light) var(--border-light) + var(--border-dark); + } + &:disabled { + color: var(--disabled-text); + pointer-events: none; + text-shadow: var(--disabled-text-shadow) 1px 1px; + text-shadow: var(--disabled-text-shadow) 0.0625rem 0.0625rem; + } + } + hr:not(.default-hr) { + border-bottom-color: var(--border-light); + border-left: 0; + border-right: 0; + border-top-color: var(--border-dark); + } + .cs-checkbox:not(input) { + position: relative; + } + .cs-checkbox input, + input.cs-checkbox, + input[type='checkbox']:not(.default-checkbox) { + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + clip: rect(0.0625rem, 0.0625rem, 0.0625rem, 0.0625rem); + border: 0; + height: 1px; + height: 0.0625rem; + overflow: hidden; + padding: 0; + width: 1px; + width: 0.0625rem; + &:focus:not(:focus-visible) { + outline: none; + } + &:focus-visible + .cs-checkbox__label, + &:focus-visible + label { + outline: dotted 2px var(--outline); + outline: dotted 0.125rem var(--outline); + outline-offset: 3px; + outline-offset: 0.1875rem; + } + &:checked + .cs-checkbox__label, + &:checked + label { + color: var(--accent); + &:before { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23c4b550' d='M2 6h1v1H2m1-2h1v1H3M2 5h1v1H2m0-2h1v1H2m1 1h1v1H3m0 0h1v1H3m1-2h1v1H4m0 0h1v1H4m0 0h1v1H4m1-2h1v1H5m0-2h1v1H5m0-2h1v1H5m1-2h1v1H6m0 0h1v1H6m0 0h1v1H6m1-2h1v1H7m0-2h1v1H7m0-2h1v1H7m1 0h1v1H8m0-2h1v1H8m0-2h1v1H8'/%3E%3C/svg%3E"); + } + } + } + .cs-checkbox input + label, + .cs-checkbox__label, + input.cs-checkbox + label, + input[type='checkbox']:not(.default-checkbox) + label, + label.cs-checkbox:has(input), + label:has(.cs-checkbox), + label:not(.default-checkbox):has( + input[type='checkbox']:not(.default-checkbox) + ) { + color: var(--secondary--text); + cursor: pointer; + display: inline-block; + line-height: 15px; + line-height: 0.9375rem; + user-select: none; + &:before { + background-color: var(--secondary-bg); + border: 1px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) + var(--border-dark); + content: ''; + display: inline-block; + height: 12px; + height: 0.75rem; + margin-right: 0.4375rem; + vertical-align: middle; + width: 12px; + width: 0.75rem; + } + &:hover { + color: #fff; + } + } + label.cs-checkbox:has(input), + label:has(.cs-checkbox), + label:not(.default-checkbox):has( + input[type='checkbox']:not(.default-checkbox) + ) { + position: relative; + &:has(.cs-checkbox:focus-visible), + &:has(input:not(.default-checkbox):focus-visible) { + outline: dotted 2px var(--outline); + outline: dotted 0.125rem var(--outline); + outline-offset: 3px; + outline-offset: 0.1875rem; + } + &:before { + margin-right: 0.1875rem; + } + &:has(.cs-checkbox:checked), + &:has(input:not(.default-checkbox):checked) { + color: var(--accent); + &:before { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23c4b550' d='M2 6h1v1H2m1-2h1v1H3M2 5h1v1H2m0-2h1v1H2m1 1h1v1H3m0 0h1v1H3m1-2h1v1H4m0 0h1v1H4m0 0h1v1H4m1-2h1v1H5m0-2h1v1H5m0-2h1v1H5m1-2h1v1H6m0 0h1v1H6m0 0h1v1H6m1-2h1v1H7m0-2h1v1H7m0-2h1v1H7m1 0h1v1H8m0-2h1v1H8m0-2h1v1H8'/%3E%3C/svg%3E"); + } + } + } + .cs-input, + input:is( + [type='text'], + [type='number'], + [type='email'], + [type='date'], + [type='datetime-local'], + [type='month'], + [type='password'], + [type='search'], + [type='tel'], + [type='url'], + [type='week'], + [type='datetime'], + [type='input'] + ):not(.default-input) { + background-color: var(--secondary-bg); + border: 1px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) + var(--border-dark); + color: var(--secondary--text); + font-size: 1rem; + line-height: 1.0625rem; + outline: 0; + padding: 0.1875rem 0.125rem 0.125rem; + &:focus + .cs-input__label, + &:focus + label { + color: var(--accent); + } + &::-moz-selection { + background-color: var(--secondary-accent); + color: #fff; + } + &::selection { + background-color: var(--secondary-accent); + color: #fff; + } + &:disabled { + background-color: var(--bg); + color: var(--text-3); + pointer-events: none; + } + &:disabled + .cs-input__label, + &:disabled + label { + color: var(--disabled-text); + pointer-events: none; + text-shadow: var(--disabled-text-shadow) 1px 1px; + text-shadow: var(--disabled-text-shadow) 0.0625rem 0.0625rem; + } + } + .cs-input__label, + label:has( + input:is( + [type='text'], + [type='number'], + [type='email'], + [type='date'], + [type='datetime-local'], + [type='month'], + [type='password'], + [type='search'], + [type='tel'], + [type='url'], + [type='week'], + [type='datetime'], + [type='input'] + ):not(.default-input) + ):not(.default-input) { + color: var(--secondary--text); + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + &:has(input:not(.default-input):focus) { + color: var(--accent); + } + &:has(input:not(.default-input):disabled) { + color: var(--disabled-text); + pointer-events: none; + text-shadow: var(--disabled-text-shadow) 1px 1px; + text-shadow: var(--disabled-text-shadow) 0.0625rem 0.0625rem; + } + } + .cs-select, + select:not(.default-select) { + appearance: none; + background-color: var(--secondary-bg); + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='4' viewBox='0 0 7 4'%3E%3Cpath fill='%23a0aa95' d='M0 0h1v1H0m1-1h1v1H1m0 0h1v1H1m1-1h1v1H2m0 0h1v1H2m1-1h1v1H3m0 0h1v1H3m0-3h1v1H3M2 0h1v1H2m1-1h1v1H3m1-1h1v1H4m0 1h1v1H4m0-2h1v1H4m1-1h1v1H5m0-2h1v1H5m1-1h1v1H6'/%3E%3C/svg%3E"); + background-position: right 6px top 50%; + background-position: right 0.375rem top 50%; + background-repeat: no-repeat; + background-size: 7px auto; + background-size: 0.4375rem auto; + border: 1px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) + var(--border-dark); + border-radius: 0; + color: var(--secondary--text); + line-height: 15px; + line-height: 0.9375rem; + min-width: 150px; + min-width: 9.375rem; + outline: 0; + padding: 0.3125rem 0.9375rem 0.3125rem 0.1875rem; + user-select: none; + &:focus-within, + &:hover { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='7' height='4' viewBox='0 0 7 4'%3E%3Cpath fill='%23fff' d='M0 0h1v1H0m1-1h1v1H1m0 0h1v1H1m1-1h1v1H2m0 0h1v1H2m1 0h1v1H3m0-2h1v1H3m0-2h1v1H3M2 0h1v1H2m1-1h1v1H3m1-1h1v1H4m0 1h1v1H4m0-2h1v1H4m1-1h1v1H5m0-2h1v1H5m1-1h1v1H6'/%3E%3C/svg%3E"); + } + option { + background-color: var(--bg); + color: var(--text-3); + } + } + .cs-select__label, + label:has(select:not(.default-select)):not(.default-select-label) { + color: var(--secondary--text); + font-size: 16px; + font-size: 1rem; + line-height: 15px; + line-height: 0.9375rem; + user-select: none; + } + .cs-fieldset, + fieldset:not(.default-fieldset) { + border: none; + user-select: none; + legend { + color: var(--secondary--text); + margin-bottom: 0.625rem; + } + > div { + padding-left: 0.625rem; + } + &:disabled { + input[type='radio'] { + + label { + color: var(--disabled-text); + pointer-events: none; + text-shadow: var(--disabled-text-shadow) 1px 1px; + text-shadow: var(--disabled-text-shadow) 0.0625rem 0.0625rem; + } + } + legend { + color: var(--disabled-text); + pointer-events: none; + text-shadow: var(--disabled-text-shadow) 1px 1px; + text-shadow: var(--disabled-text-shadow) 0.0625rem 0.0625rem; + } + } + input[type='radio'] { + opacity: 0; + + label { + color: var(--secondary--text); + cursor: pointer; + font-size: 16px; + font-size: 1rem; + line-height: 15px; + line-height: 0.9375rem; + position: relative; + &:before { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23889180' d='M10 2h1v1h-1m0 0h1v1h-1m1 0h1v1h-1m0 0h1v1h-1m0 0h1v1h-1m0 0h1v1h-1m-1 0h1v1h-1m0 0h1v1h-1m-2 0h1v1H8m1-1h1v1H9m-2 0h1v1H7m-1-1h1v1H6m-1-1h1v1H5m-3-2h1v1H2m1-1h1v1H3m1 0h1v1H4'/%3E%3Cpath fill='%23292c21' d='M1 2h1v1H1m0 0h1v1H1m1-3h1v1H2m1-1h1v1H3m1-2h1v1H4m1-1h1v1H5m1-1h1v1H6m1-1h1v1H7m1 0h1v1H8m1-1h1v1H9M0 4h1v1H0m0 0h1v1H0m0 0h1v1H0m0 0h1v1H0m1 0h1v1H1m0 0h1v1H1'/%3E%3Cpath fill='%233e4637' d='M4 1h1v1H4m1-1h1v1H5m1-1h1v1H6m1-1h1v1H7m1 0h1v1H8m1-1h1v1H9m0 0h1v1H9m0 0h1v1H9m1-1h1v1h-1m0 0h1v1h-1m0 0h1v1h-1m0 0h1v1h-1M9 7h1v1H9m0 0h1v1H9m0 0h1v1H9M8 9h1v1H8M7 9h1v1H7m0 0h1v1H7m-1-1h1v1H6m-1-1h1v1H5m-1-1h1v1H4m2-2h1v1H6M5 9h1v1H5M4 9h1v1H4M3 9h1v1H3M2 9h1v1H2m0-2h1v1H2M1 7h1v1H1m0-4h1v1H1m1-2h1v1H2m0-2h1v1H2m1-1h1v1H3m1-1h1v1H4m1-1h1v1H5m1-1h1v1H6m1-1h1v1H7M3 3h1v1H3M2 4h1v1H2m0 0h1v1H2M1 5h1v1H1m0 0h1v1H1m1-1h1v1H2m0 0h1v1H2m1 0h1v1H3m0-2h1v1H3m0-2h1v1H3m0-2h1v1H3m0-2h1v1H3m1-2h1v1H4m1-1h1v1H5m0 0h1v1H5M4 8h1v1H4m0-2h1v1H4m0-2h1v1H4m0-3h1v1H4m0 0h1v1H4m1-1h1v1H5m0 0h1v1H5m0 0h1v1H5m0 0h1v1H5m1-1h1v1H6m0-2h1v1H6m0-2h1v1H6m0-4h1v1H6m0 0h1v1H6m0 0h1v1H6m1 1h1v1H7m0 0h1v1H7m1-1h1v1H8m0-2h1v1H8M7 6h1v1H7m0-2h1v1H7m0-2h1v1H7m0-2h1v1H7m1-1h1v1H8m0 0h1v1H8m0 0h1v1H8m0 0h1v1H8m1-1h1v1H9m0-2h1v1H9'/%3E%3C/svg%3E"); + height: 12px; + height: 0.75rem; + left: -25px; + left: -1.5625rem; + top: 1px; + top: 0.0625rem; + width: 12px; + width: 0.75rem; + } + &:after, + &:before { + content: ''; + position: absolute; + } + &:after { + height: 6px; + height: 0.375rem; + left: -22px; + left: -1.375rem; + top: 4px; + top: 0.25rem; + width: 6px; + width: 0.375rem; + } + } + &:checked { + + label { + color: var(--accent); + } + + label:after { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='6' height='6' viewBox='0 0 6 6'%3E%3Cpath fill='%23c4b550' d='M1 0h1v1H1m2-1h1v1H3m1-1h1v1H4m0 0h1v1H4m1-1h1v1H5m0 0h1v1H5m0 0h1v1H5m0 0h1v1H5M4 5h1v1H4M3 5h1v1H3M2 5h1v1H2M1 5h1v1H1M0 4h1v1H0m0-2h1v1H0m0-2h1v1H0m1-1h1v1H1m0 0h1v1H1m0 0h1v1H1m1-1h1v1H2m0-2h1v1H2m0-4h1v1H2m0 0h1v1H2m1-1h1v1H3m0 1h1v1H3m0 0h1v1H3m1-1h1v1H4m0-2h1v1H4m0-2h1v1H4M3 2h1v1H3M2 2h1v1H2M1 1h1v1H1M0 1h1v1H0'/%3E%3C/svg%3E"); + } + } + } + } + .cs-slider { + display: flex; + flex-direction: column-reverse; + user-select: none; + width: 150px; + width: 9.375rem; + input { + -webkit-appearance: none; + appearance: none; + background: var(--slider-bg); + border: 1px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) + var(--border-dark); + box-sizing: border-box; + height: 4px; + height: 0.25rem; + outline: none; + width: 150px; + width: 9.375rem; + } + input::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + background: var(--bg); + border: 1px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) + var(--border-light); + border-radius: 0; + box-sizing: border-box; + cursor: pointer; + height: 16px; + height: 1rem; + width: 8px; + width: 0.5rem; + } + input::-moz-range-thumb { + -webkit-appearance: none; + appearance: none; + background: var(--bg); + border: 1px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) + var(--border-light); + border-radius: 0; + box-sizing: border-box; + cursor: pointer; + height: 16px; + height: 1rem; + width: 8px; + width: 0.5rem; + } + label { + color: var(--secondary--text); + font-size: 16px; + font-size: 1rem; + line-height: 15px; + line-height: 0.9375rem; + margin-bottom: 0.75rem; + } + &:has(input:focus) label { + color: var(--accent); + } + .ruler { + background-image: linear-gradient(to right, var(--slider) 1px, #0000 1px); + background-image: linear-gradient( + to right, + var(--slider) 0.0625rem, + #0000 0.0625rem + ); + background-size: 15px 5px; + background-size: 0.9375rem 0.3125rem; + height: 5px; + height: 0.3125rem; + margin-left: 0.25rem; + margin-top: 0.25rem; + width: calc(100% + 5px); + width: calc(100% + 0.3125rem); + z-index: -1; + } + .value { + align-items: center; + color: var(--slider); + display: flex; + font-size: 13px; + font-size: 0.8125rem; + justify-content: space-between; + line-height: 15px; + line-height: 0.9375rem; + } + } + .cs-dialog, + dialog:not(.default-dialog) { + background-color: var(--bg); + border: 1px solid; + border-color: var(--border-light) var(--border-dark) var(--border-dark) + var(--border-light); + color: var(--text); + margin: auto; + max-width: 510px; + max-width: 31.875rem; + min-width: 350px; + min-width: 21.875rem; + padding: 0.25rem; + position: fixed; + right: 0; + top: 0; + user-select: none; + .heading { + align-items: center; + display: flex; + justify-content: space-between; + margin-top: 0.1875rem; + padding-left: 0.125rem; + .wrapper { + align-items: center; + display: flex; + gap: 5px; + gap: 0.3125rem; + .icon { + background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='15' viewBox='0 0 16 15'%3E%3Cpath fill='%238c9284' d='M1 12h1v1H1m1 0h1v1H2m1-2h1v1H3m11-6h1v1h-1'/%3E%3Cpath fill='%23a5aa9c' d='M3 14h1v1H3'/%3E%3Cpath fill='%23bdbeb5' d='M0 11h1v1H0m10-9h1v1h-1m0 1h1v1h-1m2-3h1v1h-1m0 1h1v1h-1m0 2h1v1h-1'/%3E%3Cpath fill='%23fff' d='M0 10h1v1H0m0-2h1v1H0m0-2h1v1H0m1-1h1v1H1m0 0h1v1H1m0 0h1v1H1m1-1h1v1H2m0-2h1v1H2m1-1h1v1H3m0 0h1v1H3m1-2h1v1H4m0 0h1v1H4m1-1h1v1H5m-4 0h1v1H1m1-1h1v1H2m1-1h1v1H3m1-1h1v1H4m1-1h1v1H5m-2 1h1v1H3m3-6h1v1H6m0-2h1v1H6m1-1h1v1H7m0 0h1v1H7m1-2h1v1H8m0 0h1v1H8m1-2h1v1H9m0 0h1v1H9m1-2h1v1h-1m0 0h1v1h-1M7 9h1v1H7m1-1h1v1H8m1-1h1v1H9M7 6h1v1H7m1-1h1v1H8m0-2h1v1H8m0-2h1v1H8m0-2h1v1H8m-1 7h1v1H7m4-9h1v1h-1m0 0h1v1h-1m-1-1h1v1h-1m1 0h1v1h-1m1-2h1v1h-1M9 2h1v1H9m4-1h1v1h-1m0 4h1v1h-1m1-2h1v1h-1m0-2h1v1h-1m0-2h1v1h-1m0-2h1v1h-1m-4-3h1v1h-1m1-1h1v1h-1m1-1h1v1h-1'/%3E%3Cpath fill='%23848e84' d='M0 7h1v1H0m11-8h1v1h-1M7 4h1v1H7m1-4h1v1H8'/%3E%3Cpath fill='%239ca29c' d='M2 8h1v1H2m1-1h1v1H3m3 5h1v1H6m1-2h1v1H7m3-5h1v1h-1m5-6h1v1h-1m0 0h1v1h-1'/%3E%3Cpath fill='%23d6d7ce' d='M4 8h1v1H4m2 0h1v1H6'/%3E%3Cpath fill='%23dedfde' d='M4 14h1v1H4m1-1h1v1H5m3-5h1v1H8'/%3E%3Cpath fill='%23f7f7f7' d='M5 8h1v1H5m6-2h1v1h-1m1-1h1v1h-1m-1 0h1v1h-1'/%3E%3Cpath fill='%23efefef' d='M2 12h1v1H2m4 0h1v1H6m1-2h1v1H7m0-3h1v1H7m0-6h1v1H7'/%3E%3Cpath fill='%23cecfce' d='M4 12h1v1H4m1-1h1v1H5m4-7h1v1H9'/%3E%3Cpath fill='%23d6dbd6' d='M8 2h1v1H8m1-2h1v1H9m4-1h1v1h-1m1 0h1v1h-1'/%3E%3Cpath fill='%23949e94' d='M13 6h1v1h-1'/%3E%3Cpath fill='%235a6952' d='M5 9h1v1H5m1 0h1v1H6m0 0h1v1H6m0 0h1v1H6m-2 0h1v1H4m1-1h1v1H5m8-9h1v1h-1m0-3h1v1h-1m0 4h1v1h-1m-4 1h1v1H9m-2 3h1v1H7'/%3E%3Cpath fill='%23525d4a' d='M10 6h1v1h-1m1-1h1v1h-1m1-1h1v1h-1m1-3h1v1h-1m-1-3h1v1h-1m-1-1h1v1h-1m-1-1h1v1h-1M9 3h1v1H9m0 0h1v1H9m0 0h1v1H9M5 7h1v1H5m2-6h1v1H7m0 0h1v1H7m2-4h1v1H9m1-1h1v1h-1m2-1h1v1h-1m2 0h1v1h-1'/%3E%3Cpath fill='%23adb6ad' d='M6 6h1v1H6'/%3E%3C/svg%3E"); + height: 15px; + height: 0.9375rem; + width: 16px; + width: 1rem; + } + .text { + color: #fff; + font-size: 16px; + font-size: 1rem; + line-height: 15px; + line-height: 0.9375rem; + } + } + } + .content { + padding: 0.625rem; + } + .footer-btns { + float: right; + margin: 0.25rem 0.5rem 0.5rem 0; + .cs-btn { + text-align: left; + width: 72px; + width: 4.5rem; + } + } + } + .cs-tooltip { + color: #fff; + display: inline-block; + line-height: 20px; + line-height: 1.25rem; + position: relative; + user-select: none; + &:hover .text { + visibility: visible; + } + .text { + background-color: var(--secondary-accent); + border: 1px solid var(--border-dark); + color: #000; + font-size: 16px; + font-size: 1rem; + line-height: 15px; + line-height: 0.9375rem; + padding: 0.125rem 0.125rem 0.0625rem; + position: absolute; + text-align: center; + visibility: hidden; + width: max-content; + z-index: 1; + } + } + .cs-progress-bar { + background-color: var(--secondary-bg); + border: 1px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) + var(--border-dark); + height: 24px; + height: 1.5rem; + padding: 0.1875rem; + width: 260px; + width: 16.25rem; + .bars { + background-image: linear-gradient(to right, var(--accent) 8px, #0000 2px); + background-image: linear-gradient( + to right, + var(--accent) 0.5rem, + #0000 0.125rem + ); + background-size: 12px 16px; + background-size: 0.75rem 1rem; + height: 100%; + } + } + progress:not(.default-progress) { + background-color: var(--secondary-bg); + border: 1px solid; + border-color: var(--border-dark) var(--border-light) var(--border-light) + var(--border-dark); + height: 24px; + height: 1.5rem; + padding: 0.1875rem; + position: relative; + width: 260px; + width: 16.25rem; + &::-moz-progress-bar { + background-color: var(--secondary-bg); + background-image: linear-gradient( + 90deg, + #0000 0, + var(--accent) 0.01px, + var(--accent) 7.99px, + #0000 8px + ); + background-image: linear-gradient( + 90deg, + #0000 0, + var(--accent) 0.00063rem, + var(--accent) 0.49938rem, + #0000 0.5rem + ); + background-size: 12px 16px; + background-size: 0.75rem 1rem; + height: 100%; + } + &::-webkit-progress-bar, + &::-webkit-progress-value { + background-color: var(--secondary-bg); + } + &::-webkit-progress-value { + background-image: linear-gradient(to right, var(--accent) 8px, #0000 2px); + background-image: linear-gradient( + to right, + var(--accent) 0.5rem, + #0000 0.125rem + ); + background-size: 12px 16px; + background-size: 0.75rem 1rem; + height: 100%; + } + } + .cs-tabs { + align-items: center; + display: flex; + flex-wrap: wrap; + user-select: none; + .radiotab { + opacity: 0; + position: absolute; + } + .label { + background-color: var(--bg); + border-bottom: none; + border-left: 0.0625rem solid var(--border-light); + border-right: 0.0625rem solid var(--border-dark); + border-top: 0.0625rem solid var(--border-light); + color: #fff; + cursor: pointer; + font-size: 16px; + font-size: 1rem; + height: 27px; + height: 1.6875rem; + line-height: 15px; + line-height: 0.9375rem; + margin-right: 0.0625rem; + min-width: 64px; + min-width: 4rem; + padding: 0.25rem 0.3125rem; + position: relative; + text-align: left; + z-index: 10; + } + .radiotab:checked + .label { + background: var(--bg); + color: var(--accent); + height: 29px; + height: 1.8125rem; + padding: 0.3125rem; + &:before { + background-color: var(--bg); + bottom: 0; + content: ''; + height: 1px; + height: 0.0625rem; + left: 0; + position: absolute; + width: 100%; + } + } + .panel { + background: var(--bg); + border-bottom: 0.0625rem solid var(--border-dark); + border-left: 0.0625rem solid var(--border-light); + border-right: 0.0625rem solid var(--border-dark); + border-top: 0.0625rem solid var(--border-light); + bottom: 1px; + bottom: 0.0625rem; + color: var(--text); + display: none; + order: 99; + padding: 2rem 2.4375rem 1.6875rem; + position: relative; + width: 100%; + } + .radiotab:checked + .label + .panel { + display: block; + position: relative; + } + } +} diff --git a/src/hooks.server.ts b/src/hooks.server.ts new file mode 100644 index 0000000..2fe3744 --- /dev/null +++ b/src/hooks.server.ts @@ -0,0 +1,32 @@ +import * as auth from './lib/auth.server'; +import * as client from 'openid-client'; + +// https://svelte.dev/docs/kit/hooks#Server-hooks-handle +export const handle = ({ event, resolve }) => { + event.locals.auth = async () => { + const accessToken = event.cookies.get('oid__access_token'); + const sub = event.cookies.get('oid__sub'); + console.warn({ accessToken, sub }); + if (accessToken && sub) { + try { + const userInfo = await client + .fetchUserInfo(await auth.getConfig(), accessToken, sub) + .catch((e) => { + console.warn(e); + + return null; + }); + console.warn({ + userInfo, + accessToken, + sub, + }); + } catch (error) {} + } else if (accessToken || sub) { + event.cookies.delete('access-token', { path: '/' }); + event.cookies.delete('sub', { path: '/' }); + } + return null; + }; + return resolve(event); +}; diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts new file mode 100644 index 0000000..77e0dd7 --- /dev/null +++ b/src/lib/auth.server.ts @@ -0,0 +1,73 @@ +import { env as env_priv } from '$env/dynamic/private'; +import { env } from '$env/dynamic/public'; +import * as client from 'openid-client'; +import oncePromise from './oncePromise'; + +const server = new URL(env.PUBLIC_AUTH_KEYCLOAK_ISSUER); +const clientId = env_priv.PRIVATE_AUTH_KEYCLOAK_ID; +const clientSecret = env_priv.PRIVATE_AUTH_KEYCLOAK_SECRET; +const redirectPath = '/login/callback'; + +// Only trigger discovery on first client.discovery (resetting the function after a failed discovery) +export const getConfig = oncePromise(() => + client.discovery(server, clientId, clientSecret) +); +const codeVerifier = client.randomPKCECodeVerifier(); + +export const getAuthorizeUrl = async ( + currentUrl: URL | string, + scope: string[] +) => { + if (!scope.includes('openid')) scope.unshift('openid'); + // do same for `email` maybe? + + const config = await getConfig(); + const redirectUri = new URL(redirectPath, currentUrl); + const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier); + const codeChallengeMethod = 'S256'; + let nonce: string | undefined = undefined; + + // redirect user to as.authorization_endpoint + let parameters: Record = { + redirect_uri: redirectUri.href, + scope: scope.join(' '), + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + }; + + /** + * We cannot be sure the AS supports PKCE so we're going to use nonce too. Use + * of PKCE is backwards compatible even if the AS doesn't support it which is + * why we're using it regardless. + */ + if (!config.serverMetadata().supportsPKCE()) { + nonce = client.randomNonce(); + parameters.nonce = nonce; + } + + const redirectTo = client.buildAuthorizationUrl(config, parameters); + + return { + /** Defined if PKCE isnt supported */ + nonce, + /** Redirect Target URL */ + redirectTo, + /** Where we get the user back on */ + returnURI: redirectUri, + }; +}; +/** Throws on failure */ +export const authorizeNewSession = async ( + currentUrl: URL, + nonce: string | undefined +) => { + const config = await getConfig(); + + let tokens = await client.authorizationCodeGrant(config, currentUrl, { + pkceCodeVerifier: codeVerifier, + expectedNonce: nonce, + idTokenExpected: true, + }); + + return tokens; +}; diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/oncePromise.ts b/src/lib/oncePromise.ts new file mode 100644 index 0000000..f6ce775 --- /dev/null +++ b/src/lib/oncePromise.ts @@ -0,0 +1,30 @@ +const ensurePromise = (maybePromise: T | PromiseLike): Promise => + typeof maybePromise === 'object' && + maybePromise !== null && + 'then' in maybePromise && + typeof maybePromise.then === 'function' && + 'catch' in maybePromise && + typeof maybePromise.catch === 'function' && + 'finally' in maybePromise && + typeof maybePromise.finally === 'function' + ? (maybePromise as Promise) + : Promise.resolve(maybePromise); +/** Returns a function that caches successful promises until time runs out, and throws away unsuccessful ones */ +export const oncePromise = (create: () => Promise, timeout = -1) => { + let getPromise = (): Promise => { + const oldGetPromise = getPromise, + promise = ensurePromise(create()).catch((e) => { + getPromise = oldGetPromise; + throw e; + }), + expires = timeout > 0 ? performance.now() + timeout : 0; + return (getPromise = expires + ? ((() => + performance.now() > expires + ? oldGetPromise() + : promise) as () => Promise) + : () => promise)(); + }; + return () => getPromise(); +}; +export default oncePromise; diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte new file mode 100644 index 0000000..a19e467 --- /dev/null +++ b/src/routes/+error.svelte @@ -0,0 +1,6 @@ + + +

HTTP {page.status}

+

{page.error?.message}

diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts new file mode 100644 index 0000000..afdac71 --- /dev/null +++ b/src/routes/+layout.server.ts @@ -0,0 +1,5 @@ +export const load = async ({ locals }) => { + return { + // session: await locals.auth(), + }; +}; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..1980c3a --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,23 @@ + + + + + + + +{@render children?.()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..2634622 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,26 @@ + + +{@debug s} + +

SvelteKit Auth Example

+
+ {#if page.data.session} + {#if page.data.session.user?.image} + User Avatar + {/if} + + Signed in as
+ {page.data.session.user?.name ?? 'User'} +
+ + {:else} + You are not signed in + {/if} +
diff --git a/src/routes/login/+server.ts b/src/routes/login/+server.ts new file mode 100644 index 0000000..4a032d4 --- /dev/null +++ b/src/routes/login/+server.ts @@ -0,0 +1,55 @@ +import { getAuthorizeUrl } from '$lib/auth.server.js'; +import { error, redirect } from '@sveltejs/kit'; + +export const GET = async (event) => { + let target = event.url.searchParams.get('next') ?? '/'; + let desiredScopes = + event.url.searchParams.get('scope') ?? 'profile vm-own-read'; + if (new URL(target, event.url.href).host !== event.url.host) target = '/'; + const existingScopes = (event.cookies.get('oid__scopes') ?? '').split(' '); + const authed = await event.locals.auth(); + const missingScopes = !!desiredScopes + .split(' ') + .find((v) => !existingScopes.includes(v)); + if ( + // if we're not authenticated + !authed || + // or we're missing scopes + missingScopes + ) { + const { nonce, redirectTo } = await getAuthorizeUrl( + event.url.href, + desiredScopes.split(' ') + ); + if (nonce) { + let existingNonces = []; + try { + const n = JSON.parse(event.cookies.get('pending-auth-nonces') ?? '[]'); + if (Array.isArray(n) && n.length && typeof n[0] === 'string') + existingNonces = n; + } catch (error) { + // revoke all existing nonces + } + event.cookies.set( + 'pending-auth-nonces', + JSON.stringify([...existingNonces, nonce]), + { + path: '/', + httpOnly: true, + secure: true, + sameSite: true, + } + ); + } else + event.cookies.delete('pending-auth-nonces', { + path: '/', + }); + event.cookies.delete('next', { + path: target, + }); + throw redirect(303, redirectTo); + } else { + throw redirect(303, target); + } +}; +export const POST = GET; diff --git a/src/routes/login/callback/+server.ts b/src/routes/login/callback/+server.ts new file mode 100644 index 0000000..32b1647 --- /dev/null +++ b/src/routes/login/callback/+server.ts @@ -0,0 +1,80 @@ +import * as auth from '$lib/auth.server.js'; +import { error, json, redirect } from '@sveltejs/kit'; +import * as client from 'openid-client'; + +// Pre-checker for nonce, not the primary implementation +const handleNonce = (nonce: string | null, nonceCookie: string | undefined) => { + if (nonce) { + try { + const n = JSON.parse(nonceCookie ?? '[]'); + if (Array.isArray(n) && n.length && typeof n[0] === 'string') { + if (!n.includes(nonce)) throw error(400, 'Nonce not in array'); + else return n.filter((v) => v !== nonce); + } else throw error(400, 'Nonce provided, but nonce cookie not found'); + } catch (e) { + throw error(400, `Failed parsing nonce: ${e}`); + } + } else if (nonceCookie) throw error(400, 'Missing Nonce'); +}; +export const GET = async (event) => { + const sp = event.url.searchParams; + const params = { + sessionState: sp.get('session_state'), + iss: sp.get('iss'), + code: sp.get('code'), + nonce: sp.get('nonce'), + }; + if (!params.sessionState || !params.iss || !params.code) + throw error(400, 'Missing one of session_state, iss, code'); + + const remainingNonces = handleNonce( + params.nonce, + event.cookies.get('pending-auth-nonces') + ); + + try { + const tk = await auth.authorizeNewSession( + new URL(event.url.href), + params.nonce ?? undefined + ); + + for (const [k, v] of Object.entries({ + oid__access_token: tk.access_token, + oid__token_type: tk.token_type, + oid__expires_at: '' + (Date.now() + (tk.expiresIn() ?? 0) * 1000), + oid__refresh_token: tk.refresh_token, + oid__sub: tk.claims()!.sub, + 'pending-auth-nonces': JSON.stringify(remainingNonces), + })) + if (v) + event.cookies.set(k, v, { + path: '/', + secure: true, + httpOnly: true, + sameSite: true, + }); + if (tk.scope) + event.cookies.set('oid__scopes', tk.scope, { + path: '/', + secure: true, + httpOnly: true, + sameSite: true, + }); + + console.warn( + 'New Session:', + await client.fetchUserInfo( + await auth.getConfig(), + tk.access_token, + tk.claims()!.sub + ) + ); + + return json({ + sub: tk.claims()!.sub, + at: tk.access_token, + }); + } catch (e) { + throw redirect(307, '/login'); + } +}; -- cgit v1.2.3