Implement OIDC login

This commit is contained in:
2025-07-16 21:44:20 +02:00
parent f0c729cba7
commit 33c1e7278b
11 changed files with 385 additions and 37 deletions

View File

@ -1,14 +1,26 @@
"""Authentication related endpoints factory.""" """Authentication related endpoints factory."""
# pyright: reportUnusedFunction=false # pyright: reportUnusedFunction=false
import os
from datetime import datetime
import logging import logging
from typing import Annotated, Literal from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Form, HTTPException, Request, Security, status from fastapi import (
APIRouter,
Depends,
Form,
HTTPException,
Request,
Security,
status,
)
from fastapi.responses import RedirectResponse
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel from pydantic import BaseModel, ValidationError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sshecret_admin.auth import ( from sshecret_admin.auth import (
LocalUserInfo,
Token, Token,
User, User,
authenticate_user_async, authenticate_user_async,
@ -16,8 +28,12 @@ from sshecret_admin.auth import (
create_refresh_token, create_refresh_token,
decode_token, decode_token,
) )
from sshecret_admin.auth.authentication import hash_password from sshecret_admin.auth.authentication import handle_oidc_claim, hash_password
from sshecret_admin.auth.exceptions import AuthenticationFailedError
from sshecret_admin.auth.models import AuthProvider, LoginInfo
from sshecret_admin.auth.oidc import AdminOidc
from sshecret_admin.core.dependencies import AdminDependencies from sshecret_admin.core.dependencies import AdminDependencies
from sshecret_admin.core.settings import AdminServerSettings
from sshecret_admin.services import AdminBackend from sshecret_admin.services import AdminBackend
from sshecret_admin.services.models import UserPasswordChange from sshecret_admin.services.models import UserPasswordChange
@ -37,6 +53,16 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
"""Create auth router.""" """Create auth router."""
app = APIRouter() app = APIRouter()
def get_oidc_client() -> AdminOidc:
"""Get OIDC client dependency."""
if not dependencies.settings.oidc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OIDC authentication not available.",
)
oidc = AdminOidc(dependencies.settings.oidc)
return oidc
@app.post("/token") @app.post("/token")
async def login_for_access_token( async def login_for_access_token(
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)], session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
@ -122,4 +148,79 @@ def create_router(dependencies: AdminDependencies) -> APIRouter:
username=user.username, username=user.username,
) )
@app.get("/oidc/login")
async def start_oidc_login(
request: Request, oidc: Annotated[AdminOidc, Depends(get_oidc_client)]
) -> RedirectResponse:
"""Redirect for OIDC login."""
redirect_url = request.url_for("oidc_callback")
return await oidc.start_auth(request, redirect_url)
@app.get("/oidc/callback")
async def oidc_callback(
request: Request,
session: Annotated[AsyncSession, Depends(dependencies.get_async_session)],
admin: Annotated[AdminBackend, Depends(dependencies.get_admin_backend)],
oidc: Annotated[AdminOidc, Depends(get_oidc_client)],
):
"""Callback for OIDC auth."""
try:
claims = await oidc.handle_auth_callback(request)
except AuthenticationFailedError as error:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=error)
except ValidationError as error:
LOG.error("Validation error: %s", error, exc_info=True)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=error)
# We now have a IdentityClaims object.
# We need to check if this matches an existing user, or we need to create a new one.
user = await handle_oidc_claim(session, claims)
user.last_login = datetime.now()
session.add(user)
await session.commit()
# Set cookies
token_data: dict[str, str] = {"sub": claims.sub}
access_token = create_access_token(
dependencies.settings, data=token_data, provider=claims.provider
)
refresh_token = create_refresh_token(
dependencies.settings, data=token_data, provider=claims.provider
)
path = f"/auth_cb#access_token={access_token}&refresh_token={refresh_token}"
callback_url = os.path.join(dependencies.settings.frontend_url, path)
origin = "UNKNOWN"
if request.client:
origin = request.client.host
await admin.write_audit_message(
operation=Operation.LOGIN,
message="Logged in to admin frontend",
origin=origin,
username=user.username,
oidc=claims.provider,
)
return RedirectResponse(callback_url)
@app.get("/users/me")
async def get_current_user(
current_user: Annotated[User, Security(dependencies.get_current_active_user)],
) -> LocalUserInfo:
"""Get information about the user currently logged in."""
is_local = current_user.provider is AuthProvider.LOCAL
return LocalUserInfo(
id=current_user.id, display_name=current_user.username, local=is_local
)
@app.get("/oidc/status")
async def get_auth_info() -> LoginInfo:
"""Check if OIDC login is available."""
if dependencies.settings.oidc:
return LoginInfo(
enabled=True, oidc_provider=dependencies.settings.oidc.name
)
return LoginInfo(enabled=False)
return app return app

View File

@ -189,3 +189,8 @@ class LocalUserInfo(BaseModel):
local: bool local: bool
class LoginInfo(BaseModel):
"""Model containing information about login providers."""
enabled: bool
oidc_provider: str | None = None

View File

@ -40,6 +40,7 @@ class AdminServerSettings(BaseSettings):
password_manager_directory: Path | None = None password_manager_directory: Path | None = None
oidc: OidcSettings | None = None oidc: OidcSettings | None = None
frontend_origin: str = Field(default="*") frontend_origin: str = Field(default="*")
frontend_url: str
@property @property
def admin_db(self) -> URL: def admin_db(self) -> URL:

File diff suppressed because one or more lines are too long

View File

@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
import { type Options as ClientOptions, type TDataShape, type Client, urlSearchParamsBodySerializer } from './client'; import { type Options as ClientOptions, type TDataShape, type Client, urlSearchParamsBodySerializer } from './client';
import type { GetHealthHealthGetData, GetHealthHealthGetResponses, GetAuditLogApiV1AuditGetData, GetAuditLogApiV1AuditGetResponses, GetAuditLogApiV1AuditGetErrors, LoginForAccessTokenApiV1TokenPostData, LoginForAccessTokenApiV1TokenPostResponses, LoginForAccessTokenApiV1TokenPostErrors, RefreshTokenApiV1RefreshPostData, RefreshTokenApiV1RefreshPostResponses, RefreshTokenApiV1RefreshPostErrors, ChangePasswordApiV1PasswordPostData, ChangePasswordApiV1PasswordPostResponses, ChangePasswordApiV1PasswordPostErrors, GetClientsApiV1ClientsGetData, GetClientsApiV1ClientsGetResponses, CreateClientApiV1ClientsPostData, CreateClientApiV1ClientsPostResponses, CreateClientApiV1ClientsPostErrors, GetClientsTerseApiV1ClientsTerseGetData, GetClientsTerseApiV1ClientsTerseGetResponses, QueryClientsApiV1QueryClientsGetData, QueryClientsApiV1QueryClientsGetResponses, QueryClientsApiV1QueryClientsGetErrors, DeleteClientApiV1ClientsIdDeleteData, DeleteClientApiV1ClientsIdDeleteResponses, DeleteClientApiV1ClientsIdDeleteErrors, GetClientApiV1ClientsIdGetData, GetClientApiV1ClientsIdGetResponses, GetClientApiV1ClientsIdGetErrors, UpdateClientApiV1ClientsIdPutData, UpdateClientApiV1ClientsIdPutResponses, UpdateClientApiV1ClientsIdPutErrors, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteData, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteResponses, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteErrors, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutData, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutResponses, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutErrors, UpdateClientPoliciesApiV1ClientsIdPoliciesPutData, UpdateClientPoliciesApiV1ClientsIdPoliciesPutResponses, UpdateClientPoliciesApiV1ClientsIdPoliciesPutErrors, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutData, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutResponses, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutErrors, GetSystemStatsApiV1StatsGetData, GetSystemStatsApiV1StatsGetResponses, GetSecretNamesApiV1SecretsGetData, GetSecretNamesApiV1SecretsGetResponses, AddSecretApiV1SecretsPostData, AddSecretApiV1SecretsPostResponses, AddSecretApiV1SecretsPostErrors, DeleteSecretApiV1SecretsNameDeleteData, DeleteSecretApiV1SecretsNameDeleteResponses, DeleteSecretApiV1SecretsNameDeleteErrors, GetSecretApiV1SecretsNameGetData, GetSecretApiV1SecretsNameGetResponses, GetSecretApiV1SecretsNameGetErrors, UpdateSecretApiV1SecretsNamePutData, UpdateSecretApiV1SecretsNamePutResponses, UpdateSecretApiV1SecretsNamePutErrors, GetSecretGroupsApiV1SecretsGroupsGetData, GetSecretGroupsApiV1SecretsGroupsGetResponses, GetSecretGroupsApiV1SecretsGroupsGetErrors, AddSecretGroupApiV1SecretsGroupsPostData, AddSecretGroupApiV1SecretsGroupsPostResponses, AddSecretGroupApiV1SecretsGroupsPostErrors, DeleteSecretGroupApiV1SecretsGroupsGroupPathDeleteData, DeleteSecretGroupApiV1SecretsGroupsGroupPathDeleteResponses, DeleteSecretGroupApiV1SecretsGroupsGroupPathDeleteErrors, GetSecretGroupApiV1SecretsGroupsGroupPathGetData, GetSecretGroupApiV1SecretsGroupsGroupPathGetResponses, GetSecretGroupApiV1SecretsGroupsGroupPathGetErrors, UpdateSecretGroupApiV1SecretsGroupsGroupPathPutData, UpdateSecretGroupApiV1SecretsGroupsGroupPathPutResponses, UpdateSecretGroupApiV1SecretsGroupsGroupPathPutErrors, DeleteGroupIdApiV1SecretsGroupIdDeleteData, DeleteGroupIdApiV1SecretsGroupIdDeleteResponses, DeleteGroupIdApiV1SecretsGroupIdDeleteErrors, AssignSecretGroupApiV1SecretsSetGroupPostData, AssignSecretGroupApiV1SecretsSetGroupPostResponses, AssignSecretGroupApiV1SecretsSetGroupPostErrors, MoveGroupApiV1SecretsMoveGroupGroupNamePostData, MoveGroupApiV1SecretsMoveGroupGroupNamePostResponses, MoveGroupApiV1SecretsMoveGroupGroupNamePostErrors } from './types.gen'; import type { GetHealthHealthGetData, GetHealthHealthGetResponses, GetAuditLogApiV1AuditGetData, GetAuditLogApiV1AuditGetResponses, GetAuditLogApiV1AuditGetErrors, LoginForAccessTokenApiV1TokenPostData, LoginForAccessTokenApiV1TokenPostResponses, LoginForAccessTokenApiV1TokenPostErrors, RefreshTokenApiV1RefreshPostData, RefreshTokenApiV1RefreshPostResponses, RefreshTokenApiV1RefreshPostErrors, ChangePasswordApiV1PasswordPostData, ChangePasswordApiV1PasswordPostResponses, ChangePasswordApiV1PasswordPostErrors, StartOidcLoginApiV1OidcLoginGetData, StartOidcLoginApiV1OidcLoginGetResponses, OidcCallbackApiV1OidcCallbackGetData, OidcCallbackApiV1OidcCallbackGetResponses, GetCurrentUserApiV1UsersMeGetData, GetCurrentUserApiV1UsersMeGetResponses, GetAuthInfoApiV1OidcStatusGetData, GetAuthInfoApiV1OidcStatusGetResponses, GetClientsApiV1ClientsGetData, GetClientsApiV1ClientsGetResponses, CreateClientApiV1ClientsPostData, CreateClientApiV1ClientsPostResponses, CreateClientApiV1ClientsPostErrors, GetClientsTerseApiV1ClientsTerseGetData, GetClientsTerseApiV1ClientsTerseGetResponses, QueryClientsApiV1QueryClientsGetData, QueryClientsApiV1QueryClientsGetResponses, QueryClientsApiV1QueryClientsGetErrors, DeleteClientApiV1ClientsIdDeleteData, DeleteClientApiV1ClientsIdDeleteResponses, DeleteClientApiV1ClientsIdDeleteErrors, GetClientApiV1ClientsIdGetData, GetClientApiV1ClientsIdGetResponses, GetClientApiV1ClientsIdGetErrors, UpdateClientApiV1ClientsIdPutData, UpdateClientApiV1ClientsIdPutResponses, UpdateClientApiV1ClientsIdPutErrors, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteData, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteResponses, DeleteSecretFromClientApiV1ClientsIdSecretsSecretNameDeleteErrors, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutData, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutResponses, AddSecretToClientApiV1ClientsIdSecretsSecretNamePutErrors, UpdateClientPoliciesApiV1ClientsIdPoliciesPutData, UpdateClientPoliciesApiV1ClientsIdPoliciesPutResponses, UpdateClientPoliciesApiV1ClientsIdPoliciesPutErrors, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutData, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutResponses, UpdateClientPublicKeyApiV1ClientsIdPublicKeyPutErrors, GetSystemStatsApiV1StatsGetData, GetSystemStatsApiV1StatsGetResponses, GetSecretNamesApiV1SecretsGetData, GetSecretNamesApiV1SecretsGetResponses, AddSecretApiV1SecretsPostData, AddSecretApiV1SecretsPostResponses, AddSecretApiV1SecretsPostErrors, DeleteSecretApiV1SecretsNameDeleteData, DeleteSecretApiV1SecretsNameDeleteResponses, DeleteSecretApiV1SecretsNameDeleteErrors, GetSecretApiV1SecretsNameGetData, GetSecretApiV1SecretsNameGetResponses, GetSecretApiV1SecretsNameGetErrors, UpdateSecretApiV1SecretsNamePutData, UpdateSecretApiV1SecretsNamePutResponses, UpdateSecretApiV1SecretsNamePutErrors, GetSecretGroupsApiV1SecretsGroupsGetData, GetSecretGroupsApiV1SecretsGroupsGetResponses, GetSecretGroupsApiV1SecretsGroupsGetErrors, AddSecretGroupApiV1SecretsGroupsPostData, AddSecretGroupApiV1SecretsGroupsPostResponses, AddSecretGroupApiV1SecretsGroupsPostErrors, DeleteSecretGroupApiV1SecretsGroupsGroupPathDeleteData, DeleteSecretGroupApiV1SecretsGroupsGroupPathDeleteResponses, DeleteSecretGroupApiV1SecretsGroupsGroupPathDeleteErrors, GetSecretGroupApiV1SecretsGroupsGroupPathGetData, GetSecretGroupApiV1SecretsGroupsGroupPathGetResponses, GetSecretGroupApiV1SecretsGroupsGroupPathGetErrors, UpdateSecretGroupApiV1SecretsGroupsGroupPathPutData, UpdateSecretGroupApiV1SecretsGroupsGroupPathPutResponses, UpdateSecretGroupApiV1SecretsGroupsGroupPathPutErrors, DeleteGroupIdApiV1SecretsGroupIdDeleteData, DeleteGroupIdApiV1SecretsGroupIdDeleteResponses, DeleteGroupIdApiV1SecretsGroupIdDeleteErrors, AssignSecretGroupApiV1SecretsSetGroupPostData, AssignSecretGroupApiV1SecretsSetGroupPostResponses, AssignSecretGroupApiV1SecretsSetGroupPostErrors, MoveGroupApiV1SecretsMoveGroupGroupNamePostData, MoveGroupApiV1SecretsMoveGroupGroupNamePostResponses, MoveGroupApiV1SecretsMoveGroupGroupNamePostErrors } from './types.gen';
import { client as _heyApiClient } from './client.gen'; import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & { export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@ -105,6 +105,60 @@ export class SshecretAdmin {
}); });
} }
/**
* Start Oidc Login
* Redirect for OIDC login.
*/
public static startOidcLoginApiV1OidcLoginGet<ThrowOnError extends boolean = false>(options?: Options<StartOidcLoginApiV1OidcLoginGetData, ThrowOnError>) {
return (options?.client ?? _heyApiClient).get<StartOidcLoginApiV1OidcLoginGetResponses, unknown, ThrowOnError>({
responseType: 'json',
url: '/api/v1/oidc/login',
...options
});
}
/**
* Oidc Callback
* Callback for OIDC auth.
*/
public static oidcCallbackApiV1OidcCallbackGet<ThrowOnError extends boolean = false>(options?: Options<OidcCallbackApiV1OidcCallbackGetData, ThrowOnError>) {
return (options?.client ?? _heyApiClient).get<OidcCallbackApiV1OidcCallbackGetResponses, unknown, ThrowOnError>({
responseType: 'json',
url: '/api/v1/oidc/callback',
...options
});
}
/**
* Get Current User
* Get information about the user currently logged in.
*/
public static getCurrentUserApiV1UsersMeGet<ThrowOnError extends boolean = false>(options?: Options<GetCurrentUserApiV1UsersMeGetData, ThrowOnError>) {
return (options?.client ?? _heyApiClient).get<GetCurrentUserApiV1UsersMeGetResponses, unknown, ThrowOnError>({
responseType: 'json',
security: [
{
scheme: 'bearer',
type: 'http'
}
],
url: '/api/v1/users/me',
...options
});
}
/**
* Get Auth Info
* Check if OIDC login is available.
*/
public static getAuthInfoApiV1OidcStatusGet<ThrowOnError extends boolean = false>(options?: Options<GetAuthInfoApiV1OidcStatusGetData, ThrowOnError>) {
return (options?.client ?? _heyApiClient).get<GetAuthInfoApiV1OidcStatusGetResponses, unknown, ThrowOnError>({
responseType: 'json',
url: '/api/v1/oidc/status',
...options
});
}
/** /**
* Get Clients * Get Clients
* Get clients. * Get clients.

View File

@ -309,6 +309,40 @@ export type HttpValidationError = {
detail?: Array<ValidationError>; detail?: Array<ValidationError>;
}; };
/**
* LocalUserInfo
* Model used to present a user in the web ui.
*/
export type LocalUserInfo = {
/**
* Id
*/
id: string;
/**
* Display Name
*/
display_name: string;
/**
* Local
*/
local: boolean;
};
/**
* LoginInfo
* Model containing information about login providers.
*/
export type LoginInfo = {
/**
* Enabled
*/
enabled: boolean;
/**
* Oidc Provider
*/
oidc_provider?: string | null;
};
/** /**
* Operation * Operation
* Various operations for the audit logging module. * Various operations for the audit logging module.
@ -754,6 +788,66 @@ export type ChangePasswordApiV1PasswordPostResponses = {
200: unknown; 200: unknown;
}; };
export type StartOidcLoginApiV1OidcLoginGetData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/oidc/login';
};
export type StartOidcLoginApiV1OidcLoginGetResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type OidcCallbackApiV1OidcCallbackGetData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/oidc/callback';
};
export type OidcCallbackApiV1OidcCallbackGetResponses = {
/**
* Successful Response
*/
200: unknown;
};
export type GetCurrentUserApiV1UsersMeGetData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/users/me';
};
export type GetCurrentUserApiV1UsersMeGetResponses = {
/**
* Successful Response
*/
200: LocalUserInfo;
};
export type GetCurrentUserApiV1UsersMeGetResponse = GetCurrentUserApiV1UsersMeGetResponses[keyof GetCurrentUserApiV1UsersMeGetResponses];
export type GetAuthInfoApiV1OidcStatusGetData = {
body?: never;
path?: never;
query?: never;
url: '/api/v1/oidc/status';
};
export type GetAuthInfoApiV1OidcStatusGetResponses = {
/**
* Successful Response
*/
200: LoginInfo;
};
export type GetAuthInfoApiV1OidcStatusGetResponse = GetAuthInfoApiV1OidcStatusGetResponses[keyof GetAuthInfoApiV1OidcStatusGetResponses];
export type GetClientsApiV1ClientsGetData = { export type GetClientsApiV1ClientsGetData = {
body?: never; body?: never;
path?: never; path?: never;

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthTokenStore } from '@/store/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthTokenStore()
function getTokens() {
const hash = window.location.hash.substring(1)
const params = new URLSearchParams(hash)
const accessToken = params.get('access_token')
const refreshToken = params.get('refresh_token')
auth.setToken(accessToken, refreshToken)
router.push({ name: 'dashboard' })
}
onMounted(getTokens)
</script>

View File

@ -18,7 +18,7 @@
<sl-menu> <sl-menu>
<sl-menu-item>{{ auth.username }}</sl-menu-item> <sl-menu-item>{{ auth.username }}</sl-menu-item>
<sl-divider></sl-divider> <sl-divider></sl-divider>
<sl-menu-item @click="showDrawer">Change Password</sl-menu-item> <sl-menu-item @click="showDrawer" v-if="!auth.oidcUser">Change Password</sl-menu-item>
<sl-menu-item @click="logout"> Logout </sl-menu-item> <sl-menu-item @click="logout"> Logout </sl-menu-item>
</sl-menu> </sl-menu>
</sl-dropdown> </sl-dropdown>
@ -31,7 +31,7 @@
</Drawer> </Drawer>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthTokenStore } from '@/store/auth' import { useAuthTokenStore } from '@/store/auth'
@ -44,6 +44,8 @@ const router = useRouter()
const showPasswordDrawer = ref<boolean>(false) const showPasswordDrawer = ref<boolean>(false)
const localUser = ref(true)
function logout() { function logout() {
auth.logout() auth.logout()
router.push('/login') router.push('/login')
@ -52,6 +54,12 @@ function logout() {
function showDrawer() { function showDrawer() {
showPasswordDrawer.value = true showPasswordDrawer.value = true
} }
async function getUsername() {
await auth.getUserInfo()
}
onMounted(getUsername)
</script> </script>
<style scoped> <style scoped>

View File

@ -9,6 +9,7 @@ import ClientDetailPage from '@/views/clients/ClientDetailPage.vue'
import SecretDetailView from '@/views/secrets/SecretDetailView.vue' import SecretDetailView from '@/views/secrets/SecretDetailView.vue'
import SecretGroupDetailView from '@/views/secrets/SecretGroupDetailView.vue' import SecretGroupDetailView from '@/views/secrets/SecretGroupDetailView.vue'
import GenericDetail from '@/components/common/GenericDetail.vue' import GenericDetail from '@/components/common/GenericDetail.vue'
import OidcCallback from '@/components/auth/OidcCallback.vue'
import { useAuthTokenStore } from '@/store/auth' import { useAuthTokenStore } from '@/store/auth'
import { reassemblePath } from '@/api/paths' import { reassemblePath } from '@/api/paths'
@ -73,7 +74,12 @@ const routes = [
meta: { requiresAuth: true }, meta: { requiresAuth: true },
} }
], ],
} },
{
path: '/auth_cb',
name: 'oidcCallback',
component: OidcCallback,
},
] ]
const router = createRouter({ const router = createRouter({

View File

@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { client } from '@/client/client.gen' import { client } from '@/client/client.gen'
import { SshecretAdmin } from '@/client' import { SshecretAdmin } from '@/client'
import type { Token } from '@/client' import type { Token } from '@/client'
import { assertSdkResponseOk } from '@/api/assertSdkResponseOk'
export function setAuthToken(token: string | null) { export function setAuthToken(token: string | null) {
client.setConfig({ client.setConfig({
@ -16,6 +17,7 @@ export const useAuthTokenStore = defineStore('authtoken', {
refreshToken: '' as string, refreshToken: '' as string,
isLoggedIn: false, isLoggedIn: false,
username: '' as string, username: '' as string,
oidcUser: false as boolean,
}), }),
actions: { actions: {
async login(username: string, password: string): Promise<boolean> { async login(username: string, password: string): Promise<boolean> {
@ -38,6 +40,15 @@ export const useAuthTokenStore = defineStore('authtoken', {
return false return false
} }
}, },
setToken(accessToken: string, refreshToken: string) {
this.accessToken = accessToken
this.refreshToken = refreshToken
this.isLoggedIn = true
localStorage.setItem('accessToken', accessToken)
localStorage.setItem('refreshToken', refreshToken)
setAuthToken(this.accessToken)
},
async refresh(): Promise<boolean> { async refresh(): Promise<boolean> {
try { try {
console.log("Refreshing token") console.log("Refreshing token")
@ -57,6 +68,31 @@ export const useAuthTokenStore = defineStore('authtoken', {
return false return false
} }
}, },
async getUserInfo() {
try {
const response = await SshecretAdmin.getCurrentUserApiV1UsersMeGet()
const responseData = assertSdkResponseOk(response)
this.username = responseData.display_name
this.oidcUser = !responseData.local
} catch (err) {
console.log(err)
this.logout()
}
},
async getOidcProvider(): Promise<string | null> {
try {
const response = await SshecretAdmin.getAuthInfoApiV1OidcStatusGet()
const responseData = assertSdkResponseOk(response)
console.log(responseData)
if (responseData.enabled && responseData.oidc_provider) {
console.log('Yes')
return responseData.oidc_provider
}
} catch (err) {
console.log(err)
}
return null
},
loadFromStorage() { loadFromStorage() {
// Load token from user storage. // Load token from user storage.
const accessToken = localStorage.getItem('accessToken') const accessToken = localStorage.getItem('accessToken')

View File

@ -1,70 +1,92 @@
<template> <template>
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-4"> <div class="min-h-screen bg-gray-100 flex items-center justify-center p-4">
<div v-if="error" class="w-screen absolute top-0 left-0 z-50">
<sl-alert variant="danger" open v-if="error">
<sl-icon slot="icon" name="exclamation-octagon"></sl-icon>
<strong>Login failed.</strong><br />
Please check your username and password, and try again.
</sl-alert>
</div>
<div class="max-w-md w-full bg-white rounded-xl shadow-lg p-8"> <div class="max-w-md w-full bg-white rounded-xl shadow-lg p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">Sign In</h2> <h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">Sign In</h2>
<form @submit.prevent="submitLogin" class="space-y-4"> <form @submit.prevent="submitLogin" class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-400 mb-1">Username</label> <sl-input
<input label="Username"
v-model="username" class="w-full"
type="text"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all"
placeholder="Username" placeholder="Username"
autocomplete="username" autocomplete="username"
required="" required=""
/> :value="username"
@input="username = $event.target.value"
></sl-input>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label> <sl-input
label="Password"
<input
v-model="password"
type="password" type="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all" class="w-full"
:value="password"
@input="password = $event.target.value"
placeholder="••••••••" placeholder="••••••••"
autocomplete="current-password" autocomplete="current-password"
/> required=""
></sl-input>
</div> </div>
<button <sl-button type="submit" variant="primary" class="w-full"> Login </sl-button>
type="submit"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2.5 rounded-lg transition-colors"
>
Login
</button>
</form> </form>
<template v-if="oidcProvider">
<div class="w-full items-center text-center my-4 flex">
<div class="w-full h-[0.125rem] box-border bg-gray-200 dark:bg-gray-700"></div>
<div class="px-4 text-lg text-sm font-medium text-gray-500 dark:text-gray-400">Or</div>
<div class="w-full h-[0.125rem] box-border bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="w-full text-center my-4">
<sl-button outline variant="neutral" :href="oidcLoginUrl">
Sign in with {{ oidcProvider }}
</sl-button>
</div>
</template>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onMounted, ref } from 'vue'
import { useAuthTokenStore } from '@/store/auth' import { useAuthTokenStore } from '@/store/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAlertsStore } from '@/store/useAlertsStore'
import '@shoelace-style/shoelace/dist/components/alert/alert.js' import '@shoelace-style/shoelace/dist/components/alert/alert.js'
const username = ref('') const username = ref('')
const password = ref('') const password = ref('')
const error = ref(false)
const auth = useAuthTokenStore() const auth = useAuthTokenStore()
const router = useRouter() const router = useRouter()
const alerts = useAlertsStore()
const oidcProvider = ref<string | null>()
async function checkOidcProvider() {
const provider = await auth.getOidcProvider()
console.log(provider)
if (provider) {
console.log('OIDC Provider: ', provider)
oidcProvider.value = provider
}
}
const baseURL = import.meta.env.SSHECRET_FRONTEND_API_BASE_URL
const oidcLoginUrl = baseURL + '/api/v1/oidc/login'
async function submitLogin() { async function submitLogin() {
error.value = false error.value = false
const success = await auth.login(username.value, password.value) const success = await auth.login(username.value, password.value)
if (success) { if (success) {
router.push('/') router.push('/')
} else { } else {
error.value = true alerts.showAlert(
'Please check your username and password, and try again.',
'error',
'Login Failed',
)
} }
} }
onMounted(checkOidcProvider)
</script> </script>